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
|
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) ===
|
// === Dependents (Phase 1 dict-relationships-v2) ===
|
||||||
|
|
||||||
export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE'
|
export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type RecordDependentsPage,
|
type RecordDependentsPage,
|
||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
type SchemaDependent,
|
type SchemaDependent,
|
||||||
|
type SearchResponse,
|
||||||
type WebhookDeliveryPage,
|
type WebhookDeliveryPage,
|
||||||
type WebhookSubscription,
|
type WebhookSubscription,
|
||||||
} from './client'
|
} from './client'
|
||||||
@@ -363,6 +364,30 @@ export const useCascadePreview = (dict: string, businessKey: string | undefined)
|
|||||||
enabled: Boolean(businessKey),
|
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 useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||||
export const useRecords = (
|
export const useRecords = (
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ i18n
|
|||||||
'nav.audit': 'Аудит',
|
'nav.audit': 'Аудит',
|
||||||
'nav.outbox': 'Outbox',
|
'nav.outbox': 'Outbox',
|
||||||
'nav.webhooks': 'Webhooks',
|
'nav.webhooks': 'Webhooks',
|
||||||
|
'nav.search': 'Поиск',
|
||||||
|
'search.title': 'Smart search',
|
||||||
|
'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).',
|
||||||
|
'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…',
|
||||||
|
'search.tooShort': 'Минимум 3 символа для запуска поиска.',
|
||||||
|
'search.empty': 'Ничего не найдено. Попробуйте другой запрос.',
|
||||||
|
'search.totalHits_one': 'Найдено: {{count}} запись',
|
||||||
|
'search.totalHits_few': 'Найдено: {{count}} записи',
|
||||||
|
'search.totalHits_many': 'Найдено: {{count}} записей',
|
||||||
|
'search.totalHits_other': 'Найдено: {{count}} записей',
|
||||||
|
'search.intro': 'Введите запрос и нажмите Enter. Группировка результатов по справочнику; max 10 на справочник, 100 всего.',
|
||||||
'webhooks.title': 'Webhook subscriptions',
|
'webhooks.title': 'Webhook subscriptions',
|
||||||
'webhooks.subtitle': 'HTTP-доставка событий не-Kafka подписчикам',
|
'webhooks.subtitle': 'HTTP-доставка событий не-Kafka подписчикам',
|
||||||
'webhooks.empty': 'Нет подписок. Создайте первую через кнопку справа.',
|
'webhooks.empty': 'Нет подписок. Создайте первую через кнопку справа.',
|
||||||
@@ -378,6 +389,15 @@ i18n
|
|||||||
'nav.audit': 'Audit',
|
'nav.audit': 'Audit',
|
||||||
'nav.outbox': 'Outbox',
|
'nav.outbox': 'Outbox',
|
||||||
'nav.webhooks': 'Webhooks',
|
'nav.webhooks': 'Webhooks',
|
||||||
|
'nav.search': 'Search',
|
||||||
|
'search.title': 'Smart search',
|
||||||
|
'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).',
|
||||||
|
'search.placeholder': 'Type 3+ chars: code, name, identifier…',
|
||||||
|
'search.tooShort': 'Type at least 3 characters to run search.',
|
||||||
|
'search.empty': 'No results. Try a different query.',
|
||||||
|
'search.totalHits_one': '{{count}} record found',
|
||||||
|
'search.totalHits_other': '{{count}} records found',
|
||||||
|
'search.intro': 'Type a query and press Enter. Results grouped per dictionary; max 10 per dictionary, 100 total.',
|
||||||
'webhooks.title': 'Webhook subscriptions',
|
'webhooks.title': 'Webhook subscriptions',
|
||||||
'webhooks.subtitle': 'HTTP delivery of events to non-Kafka consumers',
|
'webhooks.subtitle': 'HTTP delivery of events to non-Kafka consumers',
|
||||||
'webhooks.empty': 'No subscriptions yet. Create one via the button on the right.',
|
'webhooks.empty': 'No subscriptions yet. Create one via the button on the right.',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as WebhooksRouteImport } from './routes/webhooks'
|
import { Route as WebhooksRouteImport } from './routes/webhooks'
|
||||||
|
import { Route as SearchRouteImport } from './routes/search'
|
||||||
import { Route as OutboxRouteImport } from './routes/outbox'
|
import { Route as OutboxRouteImport } from './routes/outbox'
|
||||||
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
||||||
import { Route as AuditRouteImport } from './routes/audit'
|
import { Route as AuditRouteImport } from './routes/audit'
|
||||||
@@ -24,6 +25,11 @@ const WebhooksRoute = WebhooksRouteImport.update({
|
|||||||
path: '/webhooks',
|
path: '/webhooks',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SearchRoute = SearchRouteImport.update({
|
||||||
|
id: '/search',
|
||||||
|
path: '/search',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const OutboxRoute = OutboxRouteImport.update({
|
const OutboxRoute = OutboxRouteImport.update({
|
||||||
id: '/outbox',
|
id: '/outbox',
|
||||||
path: '/outbox',
|
path: '/outbox',
|
||||||
@@ -70,6 +76,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
|
'/search': typeof SearchRoute
|
||||||
'/webhooks': typeof WebhooksRouteWithChildren
|
'/webhooks': typeof WebhooksRouteWithChildren
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
'/webhooks/$id': typeof WebhooksIdRoute
|
'/webhooks/$id': typeof WebhooksIdRoute
|
||||||
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
|
'/search': typeof SearchRoute
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
'/webhooks/$id': typeof WebhooksIdRoute
|
'/webhooks/$id': typeof WebhooksIdRoute
|
||||||
'/dictionaries': typeof DictionariesIndexRoute
|
'/dictionaries': typeof DictionariesIndexRoute
|
||||||
@@ -91,6 +99,7 @@ export interface FileRoutesById {
|
|||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
|
'/search': typeof SearchRoute
|
||||||
'/webhooks': typeof WebhooksRouteWithChildren
|
'/webhooks': typeof WebhooksRouteWithChildren
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
'/webhooks/$id': typeof WebhooksIdRoute
|
'/webhooks/$id': typeof WebhooksIdRoute
|
||||||
@@ -104,6 +113,7 @@ export interface FileRouteTypes {
|
|||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
|
| '/search'
|
||||||
| '/webhooks'
|
| '/webhooks'
|
||||||
| '/dictionaries/$name'
|
| '/dictionaries/$name'
|
||||||
| '/webhooks/$id'
|
| '/webhooks/$id'
|
||||||
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
|
| '/search'
|
||||||
| '/dictionaries/$name'
|
| '/dictionaries/$name'
|
||||||
| '/webhooks/$id'
|
| '/webhooks/$id'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
|
|||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
|
| '/search'
|
||||||
| '/webhooks'
|
| '/webhooks'
|
||||||
| '/dictionaries/$name'
|
| '/dictionaries/$name'
|
||||||
| '/webhooks/$id'
|
| '/webhooks/$id'
|
||||||
@@ -136,6 +148,7 @@ export interface RootRouteChildren {
|
|||||||
AuditRoute: typeof AuditRoute
|
AuditRoute: typeof AuditRoute
|
||||||
DictionariesRoute: typeof DictionariesRouteWithChildren
|
DictionariesRoute: typeof DictionariesRouteWithChildren
|
||||||
OutboxRoute: typeof OutboxRoute
|
OutboxRoute: typeof OutboxRoute
|
||||||
|
SearchRoute: typeof SearchRoute
|
||||||
WebhooksRoute: typeof WebhooksRouteWithChildren
|
WebhooksRoute: typeof WebhooksRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +161,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof WebhooksRouteImport
|
preLoaderRoute: typeof WebhooksRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/search': {
|
||||||
|
id: '/search'
|
||||||
|
path: '/search'
|
||||||
|
fullPath: '/search'
|
||||||
|
preLoaderRoute: typeof SearchRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/outbox': {
|
'/outbox': {
|
||||||
id: '/outbox'
|
id: '/outbox'
|
||||||
path: '/outbox'
|
path: '/outbox'
|
||||||
@@ -240,6 +260,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AuditRoute: AuditRoute,
|
AuditRoute: AuditRoute,
|
||||||
DictionariesRoute: DictionariesRouteWithChildren,
|
DictionariesRoute: DictionariesRouteWithChildren,
|
||||||
OutboxRoute: OutboxRoute,
|
OutboxRoute: OutboxRoute,
|
||||||
|
SearchRoute: SearchRoute,
|
||||||
WebhooksRoute: WebhooksRouteWithChildren,
|
WebhooksRoute: WebhooksRouteWithChildren,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ function RootLayout() {
|
|||||||
>
|
>
|
||||||
{t('nav.webhooks')}
|
{t('nav.webhooks')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/search"
|
||||||
|
className="text-carbon hover:text-ultramarain"
|
||||||
|
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||||
|
>
|
||||||
|
{t('nav.search')}
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<LanguageSwitch
|
<LanguageSwitch
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
LoadingBlock,
|
||||||
|
PageHeader,
|
||||||
|
Panel,
|
||||||
|
SearchInput,
|
||||||
|
} from '@nstart/ui'
|
||||||
|
import { useSearch } from '@/api/queries'
|
||||||
|
|
||||||
|
type SearchSearch = { q?: string }
|
||||||
|
|
||||||
|
const validateSearch = (raw: Record<string, unknown>): SearchSearch => {
|
||||||
|
if (typeof raw.q === 'string' && raw.q.length > 0) return { q: raw.q }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/search')({
|
||||||
|
component: SearchPage,
|
||||||
|
validateSearch,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart JSONB search across all dictionaries (CEO plan v1 stretch).
|
||||||
|
*
|
||||||
|
* <p>Backend: GET /api/v1/search?q=&size&perDict — ILIKE с trigram-индексом
|
||||||
|
* на dictionary_records.data::text. Min query length = 3 (trigram threshold).
|
||||||
|
*
|
||||||
|
* <p>UI: search input → grouped results per dict с per-row link на full
|
||||||
|
* record. URL state ?q=… — share-friendly.
|
||||||
|
*/
|
||||||
|
function SearchPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const urlSearch = Route.useSearch()
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
|
const [input, setInput] = useState(urlSearch.q ?? '')
|
||||||
|
|
||||||
|
const result = useSearch(urlSearch.q)
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = input.trim()
|
||||||
|
void navigate({ search: trimmed ? { q: trimmed } : {}, replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooShort = input.length > 0 && input.length < 3
|
||||||
|
const hasQuery = Boolean(urlSearch.q && urlSearch.q.length >= 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={t('search.title')}
|
||||||
|
description={t('search.description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<div className="flex-1 max-w-2xl">
|
||||||
|
<SearchInput
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
aria-label={t('search.placeholder')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{tooShort && (
|
||||||
|
<p className="text-2xs text-carbon/60">{t('search.tooShort')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasQuery && result.isLoading && (
|
||||||
|
<LoadingBlock size="md" label={t('loading')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasQuery && result.error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(result.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasQuery && result.data && result.data.total === 0 && (
|
||||||
|
<EmptyState title={t('search.empty')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasQuery && result.data && result.data.total > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-2xs text-carbon/60">
|
||||||
|
{t('search.totalHits', { count: result.data.total })}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{result.data.groups.map((g) => (
|
||||||
|
<Panel key={g.dictName}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Link
|
||||||
|
to="/dictionaries/$name"
|
||||||
|
params={{ name: g.dictName }}
|
||||||
|
search={{ q: urlSearch.q }}
|
||||||
|
className="text-sm font-primary text-ultramarain hover:underline"
|
||||||
|
>
|
||||||
|
{g.dictDisplayName ?? g.dictName}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="neutral">{g.count}</Badge>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-regolith">
|
||||||
|
{g.items.map((it) => (
|
||||||
|
<li
|
||||||
|
key={`${g.dictName}-${it.businessKey}`}
|
||||||
|
className="flex items-center justify-between gap-3 py-2 text-2xs"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/dictionaries/$name"
|
||||||
|
params={{ name: g.dictName }}
|
||||||
|
search={{ q: it.businessKey }}
|
||||||
|
className="font-mono text-ultramarain hover:underline truncate"
|
||||||
|
>
|
||||||
|
{it.businessKey}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="neutral">{it.dataScope}</Badge>
|
||||||
|
<span className="text-carbon/60">
|
||||||
|
{new Date(it.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasQuery && !tooShort && (
|
||||||
|
<p className="text-2xs text-carbon/60">{t('search.intro')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.record;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart JSONB search across all dictionaries (CEO plan v1 stretch).
|
||||||
|
*
|
||||||
|
* <p>ILIKE на {@code data::text} с trigram-индексом
|
||||||
|
* {@code idx_dict_records_data_trgm} (см. migration 0018). Active records
|
||||||
|
* only ({@code valid_from <= now() < valid_to}), фильтрация по data_scope
|
||||||
|
* запрашивающего consumer'а.
|
||||||
|
*
|
||||||
|
* <p>Per-dict cap: max {@code maxPerDict} matches на каждый словарь —
|
||||||
|
* иначе один dict с тысячей matches забьёт весь limit.
|
||||||
|
*
|
||||||
|
* <p>SQL injection: query passed как PreparedStatement param (`?`), wrap
|
||||||
|
* с `%...%` в SQL constant. Никаких user inputs не идут в SQL string.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RecordSearchQuery {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public RecordSearchQuery(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search active records по {@code q} string.
|
||||||
|
*
|
||||||
|
* @param q free-form text (>= 3 chars для использования индекса)
|
||||||
|
* @param allowedScopes scope subset виден caller'у (как text values)
|
||||||
|
* @param at snapshot moment (now() обычно)
|
||||||
|
* @param maxPerDict cap matches per dict (защита от dominant-dict skew)
|
||||||
|
* @param totalLimit hard cap всех результатов
|
||||||
|
*/
|
||||||
|
public List<SearchHit> search(
|
||||||
|
String q,
|
||||||
|
Collection<String> allowedScopes,
|
||||||
|
OffsetDateTime at,
|
||||||
|
int maxPerDict,
|
||||||
|
int totalLimit) {
|
||||||
|
if (q == null || q.length() < 3) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (maxPerDict < 1 || maxPerDict > 100) maxPerDict = 10;
|
||||||
|
if (totalLimit < 1 || totalLimit > 500) totalLimit = 100;
|
||||||
|
if (allowedScopes == null || allowedScopes.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
// Per-dict ROW_NUMBER cap. window function over partition, order by
|
||||||
|
// created_at DESC чтобы newest matches видны первыми.
|
||||||
|
String sql = """
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT
|
||||||
|
r.id, r.dictionary_id, def.name AS dict_name, def.display_name AS dict_display,
|
||||||
|
r.business_key, r.data_scope, r.created_at,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY r.dictionary_id ORDER BY r.created_at DESC) AS rn
|
||||||
|
FROM dictionary_records r
|
||||||
|
JOIN dictionary_definitions def ON def.id = r.dictionary_id
|
||||||
|
WHERE r.valid_from <= ?
|
||||||
|
AND r.valid_to > ?
|
||||||
|
AND r.data_scope = ANY(CAST(? AS TEXT[]))
|
||||||
|
AND r.data::text ILIKE ?
|
||||||
|
) t
|
||||||
|
WHERE rn <= ?
|
||||||
|
ORDER BY t.dict_name, t.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
String[] scopesArr = allowedScopes.toArray(String[]::new);
|
||||||
|
String pattern = "%" + q + "%";
|
||||||
|
|
||||||
|
return jdbc.query(
|
||||||
|
sql,
|
||||||
|
(rs, n) -> new SearchHit(
|
||||||
|
(UUID) rs.getObject("id"),
|
||||||
|
(UUID) rs.getObject("dictionary_id"),
|
||||||
|
rs.getString("dict_name"),
|
||||||
|
rs.getString("dict_display"),
|
||||||
|
rs.getString("business_key"),
|
||||||
|
rs.getString("data_scope"),
|
||||||
|
rs.getObject("created_at", OffsetDateTime.class)),
|
||||||
|
at, at, scopesArr, pattern, maxPerDict, totalLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SearchHit(
|
||||||
|
UUID id,
|
||||||
|
UUID dictionaryId,
|
||||||
|
String dictName,
|
||||||
|
String dictDisplayName,
|
||||||
|
String businessKey,
|
||||||
|
String dataScope,
|
||||||
|
OffsetDateTime createdAt) {}
|
||||||
|
}
|
||||||
@@ -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/0015-backfill-latlon-geometry.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0016-record-dependents-index.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/0017-redis-projection-flag.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+98
@@ -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) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user