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:
@@ -246,6 +246,27 @@ export type WebhookDeliveryPage = {
|
||||
size: number
|
||||
}
|
||||
|
||||
// === Smart search (CEO plan v1) ===
|
||||
|
||||
export type SearchItem = {
|
||||
businessKey: string
|
||||
dataScope: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type SearchDictGroup = {
|
||||
dictName: string
|
||||
dictDisplayName?: string | null
|
||||
count: number
|
||||
items: SearchItem[]
|
||||
}
|
||||
|
||||
export type SearchResponse = {
|
||||
query: string
|
||||
total: number
|
||||
groups: SearchDictGroup[]
|
||||
}
|
||||
|
||||
// === Dependents (Phase 1 dict-relationships-v2) ===
|
||||
|
||||
export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE'
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type RecordDependentsPage,
|
||||
type RecordResponse,
|
||||
type SchemaDependent,
|
||||
type SearchResponse,
|
||||
type WebhookDeliveryPage,
|
||||
type WebhookSubscription,
|
||||
} from './client'
|
||||
@@ -363,6 +364,30 @@ export const useCascadePreview = (dict: string, businessKey: string | undefined)
|
||||
enabled: Boolean(businessKey),
|
||||
})
|
||||
|
||||
// === Smart search (CEO plan v1) ===
|
||||
|
||||
/**
|
||||
* Free-form ILIKE search across all dictionaries (active records only).
|
||||
* Min query length = 3 (backend требует для использования trigram index).
|
||||
*/
|
||||
export const searchQuery = (q: string, size = 100, perDict = 10) =>
|
||||
queryOptions({
|
||||
queryKey: ['search', q, size, perDict] as const,
|
||||
queryFn: async (): Promise<SearchResponse> => {
|
||||
const { data } = await apiClient.get<SearchResponse>('/search', {
|
||||
params: { q, size, perDict },
|
||||
})
|
||||
return data
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
|
||||
useQuery({
|
||||
...searchQuery(q ?? '', size, perDict),
|
||||
enabled: Boolean(q && q.length >= 3),
|
||||
})
|
||||
|
||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||
export const useRecords = (
|
||||
|
||||
Reference in New Issue
Block a user