From 7094fe839f5f15bfd04d6e7c52f55d2a366ba045 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:07:40 +0400 Subject: [PATCH] =?UTF-8?q?perf(api):=20=D1=85=D0=B5=D0=BB=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D1=85=20JSON-=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=20=D0=B8=20SQL-=D0=B2=D1=8B=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены list_json_response (обёртка BaseListResponse из готовых строк), ROW_COLUMNS, row_json_select и fetch_row_texts для тяжёлых GET (AURORA-1848). Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/json_response.py | 14 ++++++++ api/src/repository/row_json.py | 17 ++++++++++ api/tests/unit/test_json_list_response.py | 41 +++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 api/src/api/v1/json_response.py create mode 100644 api/src/repository/row_json.py create mode 100644 api/tests/unit/test_json_list_response.py diff --git a/api/src/api/v1/json_response.py b/api/src/api/v1/json_response.py new file mode 100644 index 0000000..9155765 --- /dev/null +++ b/api/src/api/v1/json_response.py @@ -0,0 +1,14 @@ +"""Ответ тяжёлых GET из готовых JSON-строк Postgres (AURORA-1848, design D3).""" +from fastapi import Response + + +def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response: + """Обёртка как у BaseListResponse при успехе; rows — уже сериализованные строки ответа.""" + body = b"".join(( + b'{"success":true,"message":null,"result":[', + ",".join(rows).encode(), + b'],"count":', + str(len(rows)).encode(), + b"}", + )) + return Response(content=body, media_type="application/json", headers=headers) diff --git a/api/src/repository/row_json.py b/api/src/repository/row_json.py new file mode 100644 index 0000000..27dd415 --- /dev/null +++ b/api/src/repository/row_json.py @@ -0,0 +1,17 @@ +"""Строка ответа тяжёлого GET как текст JSON из Postgres (AURORA-1848, design D1).""" +from sqlalchemy import text +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: + """json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg.""" + return ( + f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, " + f"'data', data, 'sort_order', sort_order)::text" + ) + + +async def fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]: + return list((await db.execute(text(sql), params)).scalars().all()) diff --git a/api/tests/unit/test_json_list_response.py b/api/tests/unit/test_json_list_response.py new file mode 100644 index 0000000..bd2ff43 --- /dev/null +++ b/api/tests/unit/test_json_list_response.py @@ -0,0 +1,41 @@ +import json + +from src.api.v1.json_response import list_json_response +from src.repository.row_json import ROW_COLUMNS, row_json_select + + +def test_list_json_response_empty(): + r = list_json_response([]) + assert json.loads(r.body) == {"success": True, "message": None, "result": [], "count": 0} + + +def test_list_json_response_rows_order_count_escaping(): + rows = [ + '{"row_type":"META","depth":-1,"data":{"a":null},"sort_order":null}', + r'{"row_type": "INPUT", "depth": 3, "data": {"t": "Кавычка \" и \\ слэш\nперенос"}, "sort_order": 1}', + ] + body = json.loads(list_json_response(rows).body) + assert list(body) == ["success", "message", "result", "count"] + assert body["count"] == 2 + assert [row["row_type"] for row in body["result"]] == ["META", "INPUT"] + assert body["result"][0]["data"] == {"a": None} + assert body["result"][1]["data"]["t"] == 'Кавычка " и \\ слэш\nперенос' + + +def test_list_json_response_media_type_and_headers(): + r = list_json_response(["{}"], headers={"X-DB-Time-Ms": "1.50"}) + assert r.media_type == "application/json" + assert r.headers["content-type"] == "application/json" + assert r.headers["x-db-time-ms"] == "1.50" + assert r.headers["content-length"] == str(len(r.body)) + + +def test_row_json_select_key_order(): + assert ROW_COLUMNS == "row_type, depth, sort_order, data" + assert row_json_select() == ( + "SELECT json_build_object('row_type', row_type, 'depth', depth, " + "'data', data, 'sort_order', sort_order)::text" + ) + assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith( + "SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, " + )