perf(api): GET листа отдаёт готовый JSON из Postgres без SheetResponse на строку

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:12:09 +04:00
parent 7094fe839f
commit d8dba2f7be
4 changed files with 113 additions and 28 deletions

View File

@ -10,6 +10,7 @@ from src.services.org_unit_service import OrgUnitService
from src.db.models.form_type import FormTypeEnum from src.db.models.form_type import FormTypeEnum
from src.db.models.role import UserRoleEnum from src.db.models.role import UserRoleEnum
from src.services.sheet_service import SheetService from src.services.sheet_service import SheetService
from src.api.v1.json_response import list_json_response
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
from src.db.models.app_user import AppUser from src.db.models.app_user import AppUser
from src.services.budget_form_service import BudgetFormService from src.services.budget_form_service import BudgetFormService
@ -147,16 +148,15 @@ async def get_form_sheets(
) )
@router.get("/{form_id}/sheet/{sheet}") @router.get("/{form_id}/sheet/{sheet}", response_model=BaseListResponse[SheetResponse])
async def get_sheet( async def get_sheet(
form_id: int, form_id: int,
sheet: str, sheet: str,
response: Response,
direction: Optional[DirectionSchemaEnum] = None, direction: Optional[DirectionSchemaEnum] = None,
sections: Optional[str] = None, sections: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db), current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]: ) -> Response:
sections = _parse_sections_csv(sections) sections = _parse_sections_csv(sections)
t0 = time.perf_counter() t0 = time.perf_counter()
bf_service = BudgetFormService(db) bf_service = BudgetFormService(db)
@ -174,14 +174,13 @@ async def get_sheet(
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)): if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}") raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
result = await sheet_service.get( rows = await sheet_service.get_json(
form_id=form_id, form_id=form_id,
sheet=sheet, sheet=sheet,
direction=direction.value if direction else None, direction=direction.value if direction else None,
sections=sections, sections=sections,
) )
db_ms = (time.perf_counter() - t0) * 1000 db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={ extra={
"type": "db_time", "type": "db_time",
"time_ms": f"{db_ms:.2f}", "time_ms": f"{db_ms:.2f}",
@ -196,10 +195,7 @@ async def get_sheet(
f"db_time: {extra}", f"db_time: {extra}",
extra=extra, extra=extra,
) )
return BaseListResponse( return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"})
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.patch("/{form_id}/sheet/{sheet}/cell") @router.patch("/{form_id}/sheet/{sheet}/cell")

View File

@ -6,6 +6,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormType from src.db.models.form_type import FormType
from src.repository.row_json import ROW_COLUMNS, fetch_row_texts, row_json_select
class DirectionEnum(str, enum.Enum): class DirectionEnum(str, enum.Enum):
@ -28,13 +29,14 @@ class SheetRepository:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
async def get( def _view_source(
self, self,
form_id: int, form_id: int,
sheet: str, sheet: str,
direction: str | None, direction: str | None,
sections: list[str] | None = None, sections: list[str] | None,
) -> list[tuple]: ) -> tuple[str, dict]:
"""Валидация и FROM-выражение v3.v_form_view: общие для get (выгрузка) и get_json (GET листа)."""
if any( if any(
[ [
not isinstance(form_id, int), not isinstance(form_id, int),
@ -45,27 +47,41 @@ class SheetRepository:
): ):
raise SheetValidationError("Некорректные данные") raise SheetValidationError("Некорректные данные")
if direction: if direction:
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)" func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
else: else:
func_query = "v3.v_form_view(:form_id, :sheet, :sections)" func_query = "v3.v_form_view(:form_id, :sheet, :sections)"
return func_query, {
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
}
async def get(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
) -> list[tuple]:
func_query, params = self._view_source(form_id, sheet, direction, sections)
return ( return (
await self.db.execute( await self.db.execute(
text( text(f"SELECT {ROW_COLUMNS} FROM {func_query}"),
f""" params,
SELECT row_type, depth, sort_order, data
FROM {func_query}
"""
),
{
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
}
) )
).all() ).all()
async def get_json(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
) -> 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)
async def update_cell( async def update_cell(
self, self,

View File

@ -35,6 +35,21 @@ class SheetService:
sections=sections, sections=sections,
) )
async def get_json(
self,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
form_id: int | None = None,
form: BudgetForm | None = None
) -> list[str]:
return await self.sheet_repo.get_json(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
)
async def update_cells( async def update_cells(
self, self,
form_id: int, form_id: int,

View File

@ -3,11 +3,14 @@
Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel) Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel)
в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md
""" """
import json
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
from sqlalchemy import text
from src.main import app from src.main import app
from src.repository.sheet_repository import SheetRepository
ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin
ISP = SimpleNamespace(id=2, role_id=2) # fixture.sql: isp1, EXECUTOR_DFIP ISP = SimpleNamespace(id=2, role_id=2) # fixture.sql: isp1, EXECUTOR_DFIP
@ -64,3 +67,58 @@ def test_openapi_heavy_get_schema_unchanged():
for path in HEAVY_GET_PATHS: for path in HEAVY_GET_PATHS:
schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
assert schema == {"$ref": OPENAPI_REF}, path assert schema == {"$ref": OPENAPI_REF}, path
SHEET_CASES = [
(1, "AHR", "Support", None), # FORM_1, лист с direction
(4, "AHR", None, None), # FORM_2, без direction
(4, "AHR", None, ["plan", "q1"]), # FORM_2, с sections
]
def _sheet_params(direction, sections):
params = {}
if direction:
params["direction"] = direction
if sections:
params["sections"] = ",".join(sections)
return params
@pytest.mark.parametrize("form_id,sheet,direction,sections", SHEET_CASES)
def test_sheet_matches_old_path(client, db_session, admin_tokens, auth_headers, form_id, sheet, direction, sections):
result = get_ok(client, f"/api/v1/form/{form_id}/sheet/{sheet}", auth_headers(admin_tokens), _sheet_params(direction, sections))
old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=form_id, sheet=sheet, direction=direction, sections=sections))
assert result
assert result[0]["row_type"] == "META" and result[0]["depth"] == -1
assert result == expected_sheet(old)
TRICKY = 'Кавычка " и \\ слэш\nперенос\tтаб'
def test_sheet_text_escaping_matches_old_path(client, db_session, admin_tokens, auth_headers):
# В фикстуре у FORM_2 нет budget_line: вставляем строку с TRICKY в name (header.name листа).
# justification в ответе листа не выводится; вставка откатывается вместе с сессией теста.
async def _set():
await db_session.execute(
text(
"INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) "
"SELECT 4, t.id, :t, 'Support' FROM v3.mv_expense_item_tree t "
"JOIN v3.expense_item_form_type eft ON eft.expense_item_id = t.id "
"WHERE eft.form_type_code = 'FORM_2' AND t.sheet = 'AHR' ORDER BY t.id LIMIT 1"
),
{"t": TRICKY},
)
client.portal.call(_set)
result = get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens))
old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=4, sheet="AHR", direction=None))
assert result == expected_sheet(old)
assert any((r["data"].get("header") or {}).get("name") == TRICKY for r in result)
def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch):
async def boom(*args, **kwargs):
raise AssertionError("GET листа не должен вызывать SheetRepository.get")
monkeypatch.setattr(SheetRepository, "get", boom)
get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens))