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:
parent
a5282f1c86
commit
7efb640425
@ -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={
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user