From c8e957b13d21fa292008a153405beebc0a873d0a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 14:07:01 +0400 Subject: [PATCH] =?UTF-8?q?perf(svod2):=20=D1=83=D1=81=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=202=20=D0=B1=D0=B5=D0=B7=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поиск в 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 --- .../versions/sql/0053_svod2_server_paging.sql | 163 ++++++++++++------ api/tests/integration/test_svod2_sql.py | 134 ++++++++++++++ 2 files changed, 248 insertions(+), 49 deletions(-) diff --git a/api/alembic/versions/sql/0053_svod2_server_paging.sql b/api/alembic/versions/sql/0053_svod2_server_paging.sql index d2162c2..8bf3720 100644 --- a/api/alembic/versions/sql/0053_svod2_server_paging.sql +++ b/api/alembic/versions/sql/0053_svod2_server_paging.sql @@ -1,15 +1,40 @@ -- AURORA-1843, миграция 0053, часть 1: нормализаторы поиска как в JS и компактная проекция строки свода 2. -- Регистр без зависимости от локали: явная таблица translate (ASCII, кириллица, Ґ, латиница-1 без U+00D7). +-- Таблица в двух IMMUTABLE-функциях (свернутся в константы при планировании): svod2_lines берёт из неё +-- только пары, нужные для поиска. +CREATE OR REPLACE FUNCTION v3.svod2_casefold_from() +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT E'\u0041\u0042\u0043\u0044\u0045\u0046\u0047\u0048\u0049\u004A\u004B\u004C\u004D\u004E\u004F\u0050\u0051\u0052\u0053\u0054\u0055\u0056\u0057\u0058\u0059\u005A\u0410\u0411\u0412\u0413\u0414\u0415\u0416\u0417\u0418\u0419\u041A\u041B\u041C\u041D\u041E\u041F\u0420\u0421\u0422\u0423\u0424\u0425\u0426\u0427\u0428\u0429\u042A\u042B\u042C\u042D\u042E\u042F\u0400\u0401\u0402\u0403\u0404\u0405\u0406\u0407\u0408\u0409\u040A\u040B\u040C\u040D\u040E\u040F\u0490\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7\u00C8\u00C9\u00CA\u00CB\u00CC\u00CD\u00CE\u00CF\u00D0\u00D1\u00D2\u00D3\u00D4\u00D5\u00D6\u00D8\u00D9\u00DA\u00DB\u00DC\u00DD\u00DE' +$function$; + +CREATE OR REPLACE FUNCTION v3.svod2_casefold_to() +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT E'\u0061\u0062\u0063\u0064\u0065\u0066\u0067\u0068\u0069\u006A\u006B\u006C\u006D\u006E\u006F\u0070\u0071\u0072\u0073\u0074\u0075\u0076\u0077\u0078\u0079\u007A\u0430\u0431\u0432\u0433\u0434\u0435\u0436\u0437\u0438\u0439\u043A\u043B\u043C\u043D\u043E\u043F\u0440\u0441\u0442\u0443\u0444\u0445\u0446\u0447\u0448\u0449\u044A\u044B\u044C\u044D\u044E\u044F\u0450\u0451\u0452\u0453\u0454\u0455\u0456\u0457\u0458\u0459\u045A\u045B\u045C\u045D\u045E\u045F\u0491\u00E0\u00E1\u00E2\u00E3\u00E4\u00E5\u00E6\u00E7\u00E8\u00E9\u00EA\u00EB\u00EC\u00ED\u00EE\u00EF\u00F0\u00F1\u00F2\u00F3\u00F4\u00F5\u00F6\u00F8\u00F9\u00FA\u00FB\u00FC\u00FD\u00FE' +$function$; + CREATE OR REPLACE FUNCTION v3.svod2_casefold(x text) RETURNS text LANGUAGE sql IMMUTABLE AS $function$ - SELECT translate( - x, - E'\u0041\u0042\u0043\u0044\u0045\u0046\u0047\u0048\u0049\u004A\u004B\u004C\u004D\u004E\u004F\u0050\u0051\u0052\u0053\u0054\u0055\u0056\u0057\u0058\u0059\u005A\u0410\u0411\u0412\u0413\u0414\u0415\u0416\u0417\u0418\u0419\u041A\u041B\u041C\u041D\u041E\u041F\u0420\u0421\u0422\u0423\u0424\u0425\u0426\u0427\u0428\u0429\u042A\u042B\u042C\u042D\u042E\u042F\u0400\u0401\u0402\u0403\u0404\u0405\u0406\u0407\u0408\u0409\u040A\u040B\u040C\u040D\u040E\u040F\u0490\u00C0\u00C1\u00C2\u00C3\u00C4\u00C5\u00C6\u00C7\u00C8\u00C9\u00CA\u00CB\u00CC\u00CD\u00CE\u00CF\u00D0\u00D1\u00D2\u00D3\u00D4\u00D5\u00D6\u00D8\u00D9\u00DA\u00DB\u00DC\u00DD\u00DE', - E'\u0061\u0062\u0063\u0064\u0065\u0066\u0067\u0068\u0069\u006A\u006B\u006C\u006D\u006E\u006F\u0070\u0071\u0072\u0073\u0074\u0075\u0076\u0077\u0078\u0079\u007A\u0430\u0431\u0432\u0433\u0434\u0435\u0436\u0437\u0438\u0439\u043A\u043B\u043C\u043D\u043E\u043F\u0440\u0441\u0442\u0443\u0444\u0445\u0446\u0447\u0448\u0449\u044A\u044B\u044C\u044D\u044E\u044F\u0450\u0451\u0452\u0453\u0454\u0455\u0456\u0457\u0458\u0459\u045A\u045B\u045C\u045D\u045E\u045F\u0491\u00E0\u00E1\u00E2\u00E3\u00E4\u00E5\u00E6\u00E7\u00E8\u00E9\u00EA\u00EB\u00EC\u00ED\u00EE\u00EF\u00F0\u00F1\u00F2\u00F3\u00F4\u00F5\u00F6\u00F8\u00F9\u00FA\u00FB\u00FC\u00FD\u00FE' - ) + SELECT translate(x, v3.svod2_casefold_from(), v3.svod2_casefold_to()) +$function$; + +-- Регистр данных для поиска по уже нормализованной строке p_search: достаточно заменить символы, чьё +-- приведение даёт символ из p_search (остальные не могут совпасть ни с одним символом p_search), а при +-- p_full (p_search не нормализована) берётся полная таблица. Результат strpos(.., p_search) тот же, +-- что у svod2_casefold, но без прохода по всей таблице на каждый символ значения. +CREATE OR REPLACE FUNCTION v3.svod2_casefold_for_search(x text, p_full boolean, p_from text, p_to text) +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT CASE WHEN p_full THEN v3.svod2_casefold(x) + WHEN p_from IS NULL THEN x + ELSE translate(x, p_from, p_to) END $function$; -- Обрезка как String.prototype.trim: ECMAScript WhiteSpace и LineTerminator. @@ -240,9 +265,39 @@ $function$; DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar); DROP FUNCTION IF EXISTS v3.v_svod2_rf(integer, integer[], integer, integer, varchar, varchar); +-- Число как в поиске по экрану: ноль без обращения к plpgsql (большинство сумм - нули), иначе запись JS. +CREATE OR REPLACE FUNCTION v3.svod2_num_text(n numeric) +RETURNS text +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT CASE WHEN n IS NULL THEN NULL + WHEN n = 0 THEN '0' + ELSE v3.svod2_js_number_text(n::float8) END +$function$; + +-- Есть ли в значениях подстрока p_s (нормализованная). Одна склейка и один strpos: искомая строка без +-- U+0001 не может перекрыть границу значений, ложного совпадения на стыке нет (U+0001 внутри самих +-- значений безвреден). Со строкой, где есть U+0001, каждое значение проверяется отдельно, как matchesSearch. +CREATE OR REPLACE FUNCTION v3.svod2_vals_match( + vals text[], p_s text, p_sep_ok boolean, p_full boolean, p_from text, p_to text) +RETURNS boolean +LANGUAGE sql IMMUTABLE +AS $function$ + SELECT CASE WHEN p_sep_ok + THEN strpos(v3.svod2_casefold_for_search(array_to_string(vals, E'\u0001'), p_full, p_from, p_to), p_s) > 0 + ELSE EXISTS (SELECT 1 FROM unnest(vals) AS u(x) + WHERE strpos(v3.svod2_casefold_for_search(u.x, p_full, p_from, p_to), p_s) > 0) + END +$function$; + -- Общий отбор строк свода 2 для страниц и вариантов фильтров. Условия отбора как в 0049, -- форма выбирается по p_sheet; p_search (уже нормализованный) отсекает строки без совпадения --- по полям header, contract_summary и contract_detail, как matchesSearch экрана. +-- по значениям header, contract_summary и contract_detail, как matchesSearch экрана. +-- Значения берутся напрямую из колонок, без jsonb. Набор повторяет ключи прежнего +-- header || contract_summary || contract_detail (при совпадении ключей побеждает последний): +-- у contract_summary остаются поля, которых нет у contract_detail, а counterparty, reference, +-- deadline и comment берутся только из contract_detail (отсутствие строки contract_detail +-- затирает их у contract_summary значением null). Состав колонок сверяет тест. CREATE OR REPLACE FUNCTION v3.svod2_lines( p_sheet varchar, p_year integer, @@ -256,10 +311,22 @@ RETURNS TABLE( ) LANGUAGE sql STABLE AS $function$ -WITH empty_sections AS MATERIALIZED ( - SELECT - to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary, - to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail +WITH q AS MATERIALIZED ( + -- Разбор поисковой строки один раз на запрос (при подстановке SQL-функции в вызывающий запрос + -- выражение p_search иначе вычислялось бы на каждое значение). + -- num_ok: все символы строки возможны в записи числа JS (цифры, . - + e, Infinity, NaN); + -- иначе ни одно число не может совпасть, и числа в текст не переводятся; + -- sep_ok: в строке нет U+0001, разделителя при склейке значений строки свода; + -- fold_*: пары таблицы регистра, чей результат есть в строке (см. svod2_casefold_for_search). + SELECT p_search AS s, + p_search ~ '^[0-9.+eEinftyaINFTYA-]*$' AS num_ok, + strpos(p_search, E'\u0001') = 0 AS sep_ok, + v3.svod2_casefold(p_search) <> p_search AS fold_full, + m.f AS fold_from, m.t AS fold_to + FROM (SELECT string_agg(c.f, '' ORDER BY c.i) AS f, string_agg(c.t, '' ORDER BY c.i) AS t + FROM unnest(regexp_split_to_array(v3.svod2_casefold_from(), ''), + regexp_split_to_array(v3.svod2_casefold_to(), '')) WITH ORDINALITY AS c(f, t, i) + WHERE strpos(p_search, c.t) > 0) m ), eligible_lines AS ( -- svod2_lines eligible: begin SELECT DISTINCT @@ -380,56 +447,52 @@ WITH empty_sections AS MATERIALIZED ( SELECT el.line_id, el.form_type_code, el.form_id, el.org_unit_id, el.org_name, el.section_code, el.item_id, el.smeta_type, el.smeta_direction FROM eligible_lines el -WHERE p_search IS NULL OR EXISTS ( +CROSS JOIN q +WHERE q.s IS NULL OR 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 empty_sections 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', el.form_type_code, 'source_id', el.form_id, - 'source_org_unit_id', el.org_unit_id, 'org_name', el.org_name, - 'smeta_type', el.smeta_type) AS header_j, - COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary) AS cs_j, - CASE WHEN p_sheet = 'RF' - THEN COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail) || 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.contract_detail) - END AS cd_j + ARRAY[ei.section_code, ei.item_id, ei.num_group_id, COALESCE(bl.name, ei.name), + bl.justification, bl.internal_order, bl.direction, el.smeta_direction, + el.form_type_code, el.org_name, el.smeta_type]::text[] AS strs, + CASE WHEN q.num_ok + THEN ARRAY[ei.id::text, bl.vsp_id::text, bl.project_id::text, + el.form_id::text, el.org_unit_id::text] + END AS ints, + CASE WHEN has_contract.v + THEN ARRAY[cs.centralized_flag, cd.counterparty, cd.reference, cd.addenda, cd.subject, + cd.currency, cd.rf_schedule, cd.vat_rate, cd.deadline, cd.payment_scheme, + cd.act, cd.comment, to_jsonb(cd.contract_date) #>> '{}']::text[] + END AS rest_s, + CASE WHEN has_contract.v AND q.num_ok + THEN ARRAY[cs.total_amount, cs.future_payments_y1, cs.future_payments_y2, cs.other_ssp_amount, + cd.ceiling_amount, cd.exchange_rate, cd.amount_foreign, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q1, ck.expenses_q1) ELSE cd.expenses_q1 END, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q2, ck.expenses_q2) ELSE cd.expenses_q2 END, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q3, ck.expenses_q3) ELSE cd.expenses_q3 END, + CASE WHEN p_sheet = 'RF' THEN COALESCE(cd.expenses_q4, ck.expenses_q4) ELSE cd.expenses_q4 END]::numeric[] + END AS rest_n + FROM (SELECT cs.line_id IS NOT NULL OR cd.line_id IS NOT NULL + OR (p_sheet = 'RF' AND ck.line_id IS NOT NULL) AS v) has_contract ) s - CROSS JOIN LATERAL jsonb_each(s.header_j || s.cs_j || s.cd_j) kv WHERE bl.id = el.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), - p_search) > 0 + AND CASE + WHEN v3.svod2_vals_match(s.strs || s.ints || s.rest_s, q.s, q.sep_ok, + q.fold_full, q.fold_from, q.fold_to) THEN true + -- числа переводятся в текст только если по остальным значениям совпадения нет + WHEN s.rest_n IS NOT NULL THEN v3.svod2_vals_match( + ARRAY(SELECT v3.svod2_num_text(n) FROM unnest(s.rest_n) AS u(n)), + q.s, q.sep_ok, q.fold_full, q.fold_from, q.fold_to) + ELSE false + END ) $function$; - -- Страница свода 2 для ГО: поиск и три фильтра на сервере, total_count - число строк до LIMIT. CREATE OR REPLACE FUNCTION v3.v_svod2_go( p_year integer, @@ -449,7 +512,8 @@ RETURNS TABLE( org_name varchar, smeta_type varchar, source_line_id integer, sort_order bigint, data jsonb, total_count bigint ) -LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +-- jit off: оценка стоимости большого плана включает JIT-компиляцию на сотни миллисекунд, а выигрыша нет. +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' SET jit TO off AS $function$ WITH empty_sections AS MATERIALIZED ( SELECT @@ -667,7 +731,8 @@ RETURNS TABLE( org_name varchar, smeta_type varchar, source_line_id integer, sort_order bigint, data jsonb, total_count bigint ) -LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' +-- jit off: оценка стоимости большого плана включает JIT-компиляцию на сотни миллисекунд, а выигрыша нет. +LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog' SET jit TO off AS $function$ WITH empty_sections AS MATERIALIZED ( SELECT diff --git a/api/tests/integration/test_svod2_sql.py b/api/tests/integration/test_svod2_sql.py index 0459868..6f6b9cf 100644 --- a/api/tests/integration/test_svod2_sql.py +++ b/api/tests/integration/test_svod2_sql.py @@ -132,3 +132,137 @@ def test_facets_search_subplan_is_not_correlated(svod2_sql): 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"(?> '{}' + 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