Andrey Epifancev c8e957b13d perf(svod2): ускорен серверный поиск свода 2 без изменения результата
Поиск в v3.svod2_lines строил jsonb header || contract_summary || contract_detail на каждую
строку, разворачивал его в jsonb_each и прогонял каждое значение через svod2_casefold, а числа
через svod2_js_number_text. Теперь:
- разбор поисковой строки (числа возможны, нет U+0001, нужные пары таблицы регистра) делается
  один раз в CTE q; раньше при подстановке SQL-функции выражение нормализации p_search
  вычислялось на каждое значение каждой строки;
- значения берутся напрямую из колонок, без jsonb; склеиваются и проверяются одним strpos через
  разделитель U+0001 (со строкой поиска, где есть U+0001, значения проверяются по одному);
- регистр данных приводится только по парам таблицы, чей результат есть в строке поиска;
- числа в текст JS переводятся, только если все символы строки возможны в записи числа, и
  только если по остальным значениям совпадения нет; нули без обращения к plpgsql;
- v_svod2_go и v_svod2_rf с SET jit = off (JIT плана добавлял сотни миллисекунд).

Тесты: поиск svod2_lines равен прежнему поиску по jsonb (эталонный SQL в тесте) на обоих листах
для 55 строк, включая числа, экспоненту, NaN и Infinity, стык значений, U+0001, ненормализованные
строки; состав колонок contract_* закреплён тестом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:07:01 +04:00

269 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SQL-нормализаторы свода 2 (миграция 0053): регистр, обрезка, запись числа как в JS, компактная проекция."""
import json
from pathlib import Path
import pytest
from tests.integration.conftest import legacy_full
from tests.svod2_oracle import compact_projection
NUM = json.loads(
(Path(__file__).resolve().parent.parent / "fixtures" / "js_number_text_vectors.json").read_text(
encoding="utf-8"
)
)
def test_js_number_text_matches_node_vectors(svod2_sql):
for v in NUM:
got = svod2_sql(
"SELECT v3.svod2_js_number_text((CAST(:x AS jsonb) #>> '{}')::float8) AS t",
{"x": v["input"]},
)[0][0]
assert got == v["js"], v
def test_js_number_text_ignores_session_extra_float_digits(svod2_sql):
svod2_sql("SET LOCAL extra_float_digits = 0", None)
got = svod2_sql("SELECT v3.svod2_js_number_text(0.1::float8 + 0.2::float8) AS t", None)[0][0]
assert got == "0.30000000000000004"
@pytest.mark.parametrize(
"src",
["АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ", "ЄІЇЎЂЏҐ", "ABCxyz", "ÀÉÖÞ", "×", "аРЕНДА 1"],
)
def test_casefold_matches_js_lower(svod2_sql, src):
assert svod2_sql("SELECT v3.svod2_casefold(:s) AS t", {"s": src})[0][0] == src.lower()
def test_casefold_independent_of_collation(svod2_sql):
got = svod2_sql("""SELECT v3.svod2_casefold('ЁЛКА' COLLATE "C") AS t""", None)[0][0]
assert got == "ёлка"
def test_js_trim_ecmascript_whitespace(svod2_sql):
assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "   x \t"})[0][0] == "x"
assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": "​ x"})[0][0] == "​ x"
def test_compact_matches_python_projection(svod2_sql, svod2_data, client, admin_tokens, auth_headers):
full = legacy_full(client, "RF", auth_headers(admin_tokens))[0]["data"]
got = svod2_sql("SELECT v3.svod2_compact(CAST(:d AS jsonb)) AS c", {"d": json.dumps(full)})[0][0]
assert got == compact_projection(full)
def test_single_signature_without_overloads(svod2_sql):
rows = svod2_sql(
"SELECT proname, pronargs FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE nspname = 'v3' AND proname IN ('v_svod2_go', 'v_svod2_rf', 'v_svod2_go_export')",
None,
)
assert sorted((r.proname, r.pronargs) for r in rows) == [
("v_svod2_go", 11), ("v_svod2_go_export", 4), ("v_svod2_rf", 11)]
@pytest.mark.parametrize("fn", ["v_svod2_go", "v_svod2_rf"])
def test_page_total_count_and_order(svod2_sql, svod2_data, admin_tokens, fn):
# admin_tokens: запрос к API нужен, чтобы откат сессии клиента сработал (loop_ref задаётся при обращении к get_db)
full = svod2_sql(f"SELECT source_line_id FROM v3.{fn}(2031)", None)
page = svod2_sql(
f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, 0, 2, 'org_name', 'asc', "
"NULL, NULL, NULL, NULL, false)", None)
assert [r.source_line_id for r in page] == [r.source_line_id for r in full][:2]
assert {r.total_count for r in page} == {len(full)}
def test_facets_totals(svod2_sql, svod2_data, admin_tokens):
rows = {(r.kind, r.value): r.n for r in svod2_sql(
"SELECT * FROM v3.v_svod2_facets('GO', 2031, NULL, NULL, NULL, NULL, NULL)", None)}
assert rows[("total", None)] == rows[("search_total", None)] == len(
svod2_sql("SELECT 1 FROM v3.v_svod2_go(2031)", None))
SEARCHES = ["", " ", "аренда", "АРЕНДА", "ё", "1234.5", "1234.50", " кв ", "1", "true", "777.25", "нет такого"]
@pytest.mark.parametrize("sheet,fn", [("GO", "v_svod2_go"), ("RF", "v_svod2_rf")])
@pytest.mark.parametrize("search", SEARCHES)
def test_search_matches_screen_oracle(svod2_sql, svod2_data, client, admin_tokens, auth_headers,
sheet, fn, search):
from tests.svod2_oracle import screen_rows
rows = legacy_full(client, sheet, auth_headers(admin_tokens))
expected = [r["data"]["line_id"] for r in screen_rows(rows, search)]
got = svod2_sql(
f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, NULL, NULL, 'org_name', 'asc', :s)",
{"s": search},
)
assert sorted(r.source_line_id for r in got) == sorted(expected)
if got:
assert {r.total_count for r in got} == {len(expected)}
@pytest.mark.parametrize("sheet", ["GO", "RF"])
@pytest.mark.parametrize("search,smeta_type", [("", None), ("1", "кв "), ("аренда", "АХР"), ("", "Операц")])
def test_facets_match_screen_oracle(svod2_sql, svod2_data, client, admin_tokens, auth_headers,
sheet, search, smeta_type):
from tests.svod2_oracle import screen_facets
rows = legacy_full(client, sheet, auth_headers(admin_tokens))
filters = {"smeta_type": smeta_type} if smeta_type else {}
expected = screen_facets(rows, search, filters)
got = svod2_sql(
"SELECT kind, value, n FROM v3.v_svod2_facets(:sh, 2031, NULL, :s, :t, NULL, NULL)",
{"sh": sheet, "s": search or None, "t": smeta_type},
)
assert next(r.n for r in got if r.kind == "total") == expected["total"]
assert next(r.n for r in got if r.kind == "search_total") == expected["search_total"]
for col in ("smeta_type", "smeta_direction", "org_name"):
assert {r.value: r.n for r in got if r.kind == col} == expected[col]
def test_facets_search_subplan_is_not_correlated(svod2_sql):
import re
plan = "\n".join(r[0] for r in svod2_sql(
"EXPLAIN (VERBOSE, COSTS OFF) "
"SELECT * FROM v3.v_svod2_facets('GO', 2031, NULL, 'аренда', NULL, NULL, NULL)", None))
# IN по svod2_lines должен быть hashed SubPlan (считается один раз); коррелированный
# считался бы на каждую строку. Не-hashed SubPlan в Filter внутри svod2_lines (EXISTS поиска)
# законен, поэтому смотрим только на выражения в Output.
outputs = [l for l in plan.splitlines() if "Output:" in l and "SubPlan" in l]
assert any("hashed SubPlan" in l for l in outputs), plan
assert not any(re.search(r"(?<!hashed )SubPlan \d+", l) for l in outputs), plan
# --- Поиск в svod2_lines (ускорение AURORA-1843): результат равен прежнему поиску по jsonb ---
# Прежний поиск дословно: значения header + contract_summary + contract_detail через jsonb_each,
# регистр svod2_casefold по каждому значению, числа через svod2_js_number_text.
REFERENCE_SEARCH_SQL = """
SELECT l.line_id
FROM v3.svod2_lines(:sh, 2031, NULL, NULL) l
WHERE EXISTS (
SELECT 1
FROM v3.budget_line bl
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id
LEFT JOIN v3.contract_detail cd ON cd.line_id = bl.id
LEFT JOIN v3.ckk ck ON ck.line_id = bl.id
CROSS JOIN LATERAL (
SELECT
to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS cs0,
to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS cd0
) e
CROSS JOIN LATERAL (
SELECT
jsonb_build_object(
'section_code', ei.section_code, 'item_id', ei.item_id,
'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name),
'expense_item_id', ei.id, 'justification', bl.justification,
'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id,
'project_id', bl.project_id, 'direction', bl.direction,
'smeta_direction', CASE bl.direction
WHEN 'Support' THEN 'Поддержка' WHEN 'Development' THEN 'Развитие' ELSE NULL END,
'source_form', l.form_type_code, 'source_id', l.form_id,
'source_org_unit_id', l.org_unit_id, 'org_name', l.org_name,
'smeta_type', l.smeta_type) AS header_j,
COALESCE(to_jsonb(cs) - 'line_id', e.cs0) AS cs_j,
CASE WHEN :sh = 'RF'
THEN COALESCE(to_jsonb(cd) - 'line_id', e.cd0) || jsonb_build_object(
'expenses_q1', COALESCE(cd.expenses_q1, ck.expenses_q1),
'expenses_q2', COALESCE(cd.expenses_q2, ck.expenses_q2),
'expenses_q3', COALESCE(cd.expenses_q3, ck.expenses_q3),
'expenses_q4', COALESCE(cd.expenses_q4, ck.expenses_q4))
ELSE COALESCE(to_jsonb(cd) - 'line_id', e.cd0)
END AS cd_j
) s
CROSS JOIN LATERAL jsonb_each(s.header_j || s.cs_j || s.cd_j) kv
WHERE bl.id = l.line_id
AND strpos(
v3.svod2_casefold(
CASE jsonb_typeof(kv.value)
WHEN 'string' THEN kv.value #>> '{}'
WHEN 'number' THEN v3.svod2_js_number_text((kv.value #>> '{}')::float8)
WHEN 'boolean' THEN kv.value #>> '{}'
WHEN 'null' THEN ''
ELSE kv.value::text
END),
:s) > 0
)
"""
# Данные для границ ускорения: соседние значения строки свода (name и justification идут подряд при склейке),
# U+0001 внутри значения, числа с экспонентой и отрицательные, ckk без contract_detail, дата.
SEARCH_EXTRA_DATA = [
"UPDATE v3.budget_line SET name = 'ab', justification = 'cd' WHERE id = 910001",
"UPDATE v3.budget_line SET internal_order = E'x\\u0001y' WHERE id = 910004",
"INSERT INTO v3.contract_detail (line_id, counterparty, reference, ceiling_amount, expenses_q1, expenses_q2, "
"exchange_rate, amount_foreign, contract_date, act) VALUES "
"(910007, 'ЗАО Эй', 'Ref-А', 15000000000000000000000000, 12.5, 0, 0.0000001, -0.0000001234, '2031-03-04', 'Infinity')",
"UPDATE v3.contract_detail SET exchange_rate = 'NaN', amount_foreign = '-Infinity' WHERE line_id = 910002",
"INSERT INTO v3.ckk (line_id, ceiling_amount, expenses_q1, expenses_q2) VALUES (910009, 1, 555.5, 6)",
]
# Нормализованные (как приходят от v_svod2_*) и ненормализованные строки; по ветвям ускорения:
# только символы числа (числа переводятся в текст) и нет (не переводятся), есть/нет пар таблицы регистра,
# U+0001 в строке, стык значений, пустая строка.
SEARCH_EQUIV = [
"", "аренд", "ё", "ёлк", "зао", "эй", "ref-а", "ооо", "д-", "zzzz", "а",
"1", "0", "-", ".", "e", "+", "e+25", "1.5e+25", "1e-7", "-1.234e-7", "-1", "555.5", "777.25", "12.5", "1234.5",
"infinity", "nan", "a", "nt", "inf", "2031-03-04", "03-04", "12",
"abcd", "ab", "cd", "bc", "abc", "ab\u0001cd", "x\u0001y", "x", "y", "\u0001", "y\u0001", "\u0001x", "аренд\u0001",
"АРЕНД", "Ё", "E", "Infinity", "ЭЙ", "Ab", "REF",
]
def test_search_columns_are_those_known_to_svod2_lines(svod2_sql):
# svod2_lines перечисляет значения для поиска явно (по колонкам, без jsonb). Новая колонка в
# contract_summary/contract_detail не попадёт в поиск, пока её не добавят в svod2_lines: тест просит это сделать.
# Из contract_summary в поиск идут только колонки, которых нет в contract_detail: прежний
# header || contract_summary || contract_detail затирал одноимённые.
def columns(table: str) -> set[str]:
return {r[0] for r in svod2_sql(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'v3' AND table_name = :t", {"t": table})}
assert columns("contract_summary") == {
"line_id", "total_amount", "counterparty", "reference", "deadline", "comment",
"future_payments_y1", "future_payments_y2", "other_ssp_amount", "centralized_flag"}
assert columns("contract_detail") == {
"line_id", "counterparty", "reference", "addenda", "subject", "currency", "ceiling_amount",
"expenses_q1", "expenses_q2", "expenses_q3", "expenses_q4", "rf_schedule", "vat_rate",
"exchange_rate", "amount_foreign", "deadline", "payment_scheme", "act", "comment", "contract_date"}
# ключи header не пересекаются с колонками contract_*
header_keys = {
"section_code", "item_id", "num_group", "name", "expense_item_id", "justification",
"internal_order", "vsp_id", "project_id", "direction", "smeta_direction", "source_form",
"source_id", "source_org_unit_id", "org_name", "smeta_type"}
assert not header_keys & (columns("contract_summary") | columns("contract_detail"))
@pytest.mark.parametrize("sheet", ["GO", "RF"])
@pytest.mark.parametrize("search", SEARCH_EQUIV, ids=[repr(s) for s in SEARCH_EQUIV])
def test_svod2_lines_search_equals_reference(svod2_sql, svod2_data, sheet, search):
for statement in SEARCH_EXTRA_DATA:
svod2_sql(statement)
got = svod2_sql(
"SELECT line_id FROM v3.svod2_lines(:sh, 2031, NULL, :s)", {"sh": sheet, "s": search})
expected = svod2_sql(REFERENCE_SEARCH_SQL, {"sh": sheet, "s": search})
assert sorted(r[0] for r in got) == sorted(r[0] for r in expected)
def test_svod2_lines_search_extra_data_is_searchable(svod2_sql, svod2_data):
# Страховка от пустых сравнений: на этих данных поиск что-то находит и что-то отсекает.
for statement in SEARCH_EXTRA_DATA:
svod2_sql(statement)
def found(sheet: str, search: str) -> int:
return len(svod2_sql(
"SELECT line_id FROM v3.svod2_lines(:sh, 2031, NULL, :s)", {"sh": sheet, "s": search}))
assert found("GO", "ab") == 1 and found("GO", "cd") == 1
assert found("GO", "abcd") == 0 and found("GO", "ab\u0001cd") == 0 # стык значений
assert found("GO", "x\u0001y") == 1 # U+0001 внутри значения
assert found("RF", "e+25") == 1 and found("RF", "-1.234e-7") == 1 # число как в JS
assert found("RF", "555.5") == 1 # ckk без contract_detail
assert found("RF", "zzzz") == 0