feat(search): smart JSONB search across all dictionaries

CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.

Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
  явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
    ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
  ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.

Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
  per-dict cap. SQL injection защищён PreparedStatement param + extension
  whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
  Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
  Scope filtering through ScopeContext — каждый caller видит только свои
  scope levels. Results grouped per dict с display name + count + items.

Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.

Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
  не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
  search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
  index server (Elastic / Meili).

Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.
This commit is contained in:
Zimin A.N.
2026-05-08 12:53:44 +03:00
parent 08405fecf7
commit 48df5ceeb8
10 changed files with 489 additions and 0 deletions
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
CEO plan v1: smart JSONB search across all dictionaries.
pg_trgm extension + GIN expression index на data::text для ILIKE с
trigram match. Запросы вида `data::text ILIKE '%spacecraft%'` идут по
индексу когда query >= 3 chars (trigram threshold).
Alternative considered: jsonb_to_tsvector full-text. Rejected because:
- 'russian' dictionary stem'ит коды (SAR-X → "sar"), теряет точность.
- 'simple' tsvector работает но требует to_tsquery('q & q2 & q3'),
слишком сложно для admin search bar (admins пишут free-form).
- Trigram natively supports ILIKE/contains semantics, что и нужно.
Index size: ~30-40% от data column на 5k records (~3-5 MB). Acceptable.
-->
<changeSet id="0018-pg-trgm-extension" author="ordinis">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="t">
SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')
</sqlCheck>
</not>
</preConditions>
<comment>Enable pg_trgm для trigram-based ILIKE search</comment>
<sql>CREATE EXTENSION IF NOT EXISTS pg_trgm;</sql>
<rollback>
<!-- Не дропаем extension в rollback'е — может быть использовано other tables. -->
<sql>SELECT 1;</sql>
</rollback>
</changeSet>
<changeSet id="0018-trgm-search-index" author="ordinis">
<comment>GIN trigram index на dictionary_records.data::text для smart search</comment>
<sql>
CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records
USING GIN ((data::text) gin_trgm_ops);
</sql>
<rollback>
<sql>DROP INDEX IF EXISTS idx_dict_records_data_trgm;</sql>
</rollback>
</changeSet>
</databaseChangeLog>
@@ -27,5 +27,6 @@
<include file="changes/0015-backfill-latlon-geometry.xml" relativeToChangelogFile="true"/>
<include file="changes/0016-record-dependents-index.xml" relativeToChangelogFile="true"/>
<include file="changes/0017-redis-projection-flag.xml" relativeToChangelogFile="true"/>
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>