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,98 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.record.RecordSearchQuery;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Smart search across all dictionaries (CEO plan v1 stretch).
*
* <p>{@code GET /api/v1/search?q=…&size=…&perDict=…} — free-form ILIKE
* по {@code dictionary_records.data::text} с trigram индексом. Возвращает
* grouped results per dict {@link RecordSearchQuery.SearchHit}.
*
* <p>Min query length = 3 (чтобы использовать trigram index). Backend
* silently возвращает empty list для shorter queries.
*
* <p>Scope filter: только dicts которых caller видит через scope.
*
* <p>Phase v1 limits: max 10 per dict, max 100 total. Достаточно для admin
* "find this code" use case. Для browser-style search-as-you-type — нужен
* dedicated index server (Elastic / Meili) — отложен в v2.
*/
@RestController
@RequestMapping("/api/v1/search")
public class SearchController {
private final RecordSearchQuery searchQuery;
private final ScopeContext scopeContext;
public SearchController(RecordSearchQuery searchQuery, ScopeContext scopeContext) {
this.searchQuery = searchQuery;
this.scopeContext = scopeContext;
}
/**
* @param q free-form text (min 3 chars; <3 returns empty).
* @param size total cap (default 100, max 500).
* @param perDict per-dictionary cap (default 10, max 100).
* @return grouped по dict_name с display name + count + items.
*/
@GetMapping
public SearchResponse search(
@RequestParam String q,
@RequestParam(defaultValue = "100") int size,
@RequestParam(defaultValue = "10") int perDict) {
var scopeStrings = scopeContext.currentScopes().stream()
.map(DataScope::name)
// DB column data_scope использует lowercase per CHECK constraint
// ("public"/"internal"/"restricted"). Mapping enum → lowercase.
.map(String::toLowerCase)
.collect(Collectors.toList());
List<RecordSearchQuery.SearchHit> hits = searchQuery.search(
q, scopeStrings, OffsetDateTime.now(), perDict, size);
// Group by dict for clean UI rendering.
Map<String, List<RecordSearchQuery.SearchHit>> grouped = hits.stream()
.collect(Collectors.groupingBy(
RecordSearchQuery.SearchHit::dictName,
java.util.LinkedHashMap::new,
Collectors.toList()));
List<DictGroup> groups = grouped.entrySet().stream()
.map(e -> {
var first = e.getValue().get(0);
return new DictGroup(
e.getKey(),
first.dictDisplayName(),
e.getValue().size(),
e.getValue().stream()
.map(h -> new SearchItem(
h.businessKey(), h.dataScope(), h.createdAt()))
.toList());
})
.toList();
return new SearchResponse(q, hits.size(), groups);
}
public record SearchItem(String businessKey, String dataScope, OffsetDateTime createdAt) {}
public record DictGroup(
String dictName,
String dictDisplayName,
int count,
List<SearchItem> items) {}
public record SearchResponse(String query, int total, List<DictGroup> groups) {}
}