From 7efb640425de0bd51da31b1d8b8fb4cc0d8846cf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:56:54 +0400 Subject: [PATCH] =?UTF-8?q?perf(api):=20=D0=BB=D0=B8=D1=81=D1=82=20=D0=B8?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B4=D1=8B=20=D0=B1=D0=B5=D0=B7=20null-?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B2=20data=20(jsonb=5Fstrip?= =?UTF-8?q?=5Fnulls)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/src/api/v1/forms.py | 1 + api/src/api/v1/summary.py | 2 + api/src/repository/row_json.py | 5 +- api/src/repository/sheet_repository.py | 3 +- api/src/repository/summary_repository.py | 6 ++- api/src/services/sheet_service.py | 4 +- api/src/services/summary_service.py | 4 ++ api/tests/integration/test_heavy_get_json.py | 54 +++++++++++++++++++- api/tests/unit/test_json_list_response.py | 8 +++ 9 files changed, 79 insertions(+), 8 deletions(-) diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index 9e120d4..cad99dd 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -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={ diff --git a/api/src/api/v1/summary.py b/api/src/api/v1/summary.py index e3a2e9f..2a18ecd 100644 --- a/api/src/api/v1/summary.py +++ b/api/src/api/v1/summary.py @@ -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 diff --git a/api/src/repository/row_json.py b/api/src/repository/row_json.py index 27dd415..9a9778b 100644 --- a/api/src/repository/row_json.py +++ b/api/src/repository/row_json.py @@ -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" ) diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index 3fc0ed1..22795d6 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -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, diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 8dc0070..444a0bd 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -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)}", diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index 06a5eb9..f1b24b4 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -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( diff --git a/api/src/services/summary_service.py b/api/src/services/summary_service.py index 71c9a08..737c351 100644 --- a/api/src/services/summary_service.py +++ b/api/src/services/summary_service.py @@ -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( diff --git a/api/tests/integration/test_heavy_get_json.py b/api/tests/integration/test_heavy_get_json.py index f4a432a..612acaf 100644 --- a/api/tests/integration/test_heavy_get_json.py +++ b/api/tests/integration/test_heavy_get_json.py @@ -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 diff --git a/api/tests/unit/test_json_list_response.py b/api/tests/unit/test_json_list_response.py index bd2ff43..96d0d9d 100644 --- a/api/tests/unit/test_json_list_response.py +++ b/api/tests/unit/test_json_list_response.py @@ -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()