mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(search): add database structure search by column name and comment
This commit is contained in:
@@ -7,11 +7,14 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { buildDatabaseSearchSql, buildSearchResultWhere, findMatchedSearchColumns } from "@/lib/database/databaseSearch";
|
||||
import { databaseSearchBatchRange, databaseSearchNextBatchSize } from "@/lib/database/databaseSearchBatch";
|
||||
import type { DatabaseSearchResultItem, DatabaseSearchTableTask, DatabaseSearchTabState, DatabaseType, TableInfo } from "@/types/database";
|
||||
import type { DatabaseSearchResultItem, DatabaseSearchScope, DatabaseSearchTableTask, DatabaseSearchTabState, DatabaseType, TableInfo } from "@/types/database";
|
||||
import { buildDatabaseStructureSearchSql, effectiveDatabaseSearchScope, runDatabaseStructureSearch, structureHitPreview, supportsDatabaseStructureSearchFromManifest } from "@/lib/database/databaseStructureSearch";
|
||||
import { usesDocumentStructureSearch, usesSqlCatalogStructureSearch } from "@/lib/database/databaseStructureSearchCapabilities";
|
||||
import type { NavigationTarget } from "@/composables/useNavigationTargets";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
|
||||
@@ -44,6 +47,7 @@ const SYSTEM_SCHEMAS = new Set(["information_schema", "pg_catalog", "sys", "syst
|
||||
const keywordInputRef = ref<any>(null);
|
||||
|
||||
const keyword = ref(props.initialState?.keyword ?? "");
|
||||
const searchScope = ref<DatabaseSearchScope>(props.initialState?.searchScope ?? "data");
|
||||
const perTableLimit = ref(props.initialState?.perTableLimit ?? 20);
|
||||
const running = ref(false);
|
||||
const cancelled = ref(false);
|
||||
@@ -63,6 +67,9 @@ let runId = 0;
|
||||
const connection = computed(() => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined));
|
||||
const scopeLabel = computed(() => [connection.value?.name, props.database, props.schema].filter(Boolean).join(" / "));
|
||||
const canSearch = computed(() => Boolean(props.connectionId && props.database && keyword.value.trim() && !running.value));
|
||||
const structureSearchAvailable = computed(() => supportsDatabaseStructureSearchFromManifest(connection.value?.db_type, connection.value?.driver_profile));
|
||||
const effectiveSearchScope = computed(() => effectiveDatabaseSearchScope(searchScope.value, connection.value?.db_type, connection.value?.driver_profile));
|
||||
const isStructureMode = computed(() => effectiveSearchScope.value === "structure");
|
||||
const progressLabel = computed(() => t("databaseSearch.progress", { done: progressDone.value, total: progressTotal.value }));
|
||||
const remainingTableCount = computed(() => Math.max(0, tableTasks.value.length - nextTableIndex.value));
|
||||
const nextBatchSize = computed(() => databaseSearchNextBatchSize(nextTableIndex.value, tableTasks.value.length));
|
||||
@@ -71,6 +78,7 @@ const canContinueSearch = computed(() => !running.value && !loadingTables.value
|
||||
function getCurrentState(): DatabaseSearchTabState {
|
||||
return {
|
||||
keyword: keyword.value,
|
||||
searchScope: searchScope.value,
|
||||
perTableLimit: perTableLimit.value,
|
||||
results: results.value,
|
||||
tableErrors: tableErrors.value,
|
||||
@@ -88,7 +96,7 @@ function emitState() {
|
||||
emit("update:state", getCurrentState());
|
||||
}
|
||||
|
||||
watch([keyword, perTableLimit], () => {
|
||||
watch([keyword, searchScope, perTableLimit], () => {
|
||||
emitState();
|
||||
});
|
||||
|
||||
@@ -97,6 +105,7 @@ watch(
|
||||
(incoming) => {
|
||||
if (!incoming || running.value) return;
|
||||
if (incoming.keyword !== undefined && incoming.keyword !== keyword.value) keyword.value = incoming.keyword;
|
||||
if (incoming.searchScope !== undefined && incoming.searchScope !== searchScope.value) searchScope.value = incoming.searchScope;
|
||||
if (incoming.perTableLimit !== undefined && incoming.perTableLimit !== perTableLimit.value) perTableLimit.value = incoming.perTableLimit;
|
||||
if (incoming.results) results.value = [...incoming.results];
|
||||
if (incoming.tableErrors) tableErrors.value = [...incoming.tableErrors];
|
||||
@@ -188,11 +197,30 @@ async function startSearch() {
|
||||
running.value = true;
|
||||
loadingTables.value = true;
|
||||
activeKeyword.value = keyword.value.trim();
|
||||
activePerTableLimit.value = Math.min(100, Math.max(1, Math.trunc(Number(perTableLimit.value) || 20)));
|
||||
activePerTableLimit.value = Math.min(500, Math.max(1, Math.trunc(Number(perTableLimit.value) || 20)));
|
||||
emitState();
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
if (isStructureMode.value) {
|
||||
const databaseType = connection.value.db_type;
|
||||
let useStructurePath = usesDocumentStructureSearch(databaseType);
|
||||
if (!useStructurePath && usesSqlCatalogStructureSearch(databaseType)) {
|
||||
const catalogSql = await buildDatabaseStructureSearchSql({
|
||||
databaseType,
|
||||
driverProfile: connection.value.driver_profile,
|
||||
database: props.database,
|
||||
schema: props.schema,
|
||||
term: activeKeyword.value,
|
||||
limit: activePerTableLimit.value,
|
||||
});
|
||||
useStructurePath = Boolean(catalogSql?.sql);
|
||||
}
|
||||
if (useStructurePath) {
|
||||
await runStructureSearch(currentRun);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tableTasks.value = await listSearchTables();
|
||||
if (currentRun !== runId || cancelled.value) return;
|
||||
progressTotal.value = tableTasks.value.length;
|
||||
@@ -239,6 +267,70 @@ async function continueSearch(scanAllRemaining: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runStructureSearch(currentRun: number) {
|
||||
const databaseType = connection.value!.db_type;
|
||||
progressTotal.value = 1;
|
||||
progressDone.value = 0;
|
||||
loadingTables.value = false;
|
||||
emitState();
|
||||
const executionId = makeExecutionId();
|
||||
currentExecutionId.value = executionId;
|
||||
try {
|
||||
const schemaNames = props.schema ? [props.schema] : isSchemaAware(databaseType) ? filterSearchSchemas(await api.listSchemas(props.connectionId, props.database)) : [undefined];
|
||||
const limit = Math.min(5000, Math.max(1, activePerTableLimit.value * 10));
|
||||
const merged: DatabaseSearchResultItem[] = [];
|
||||
const tableTypeByName = new Map<string, string>();
|
||||
for (const schema of schemaNames) {
|
||||
if (currentRun !== runId || cancelled.value) return;
|
||||
const resolvedSchema = schema ?? props.database;
|
||||
const catalogTables = await api.listTables(props.connectionId, props.database, resolvedSchema);
|
||||
for (const table of catalogTables) {
|
||||
tableTypeByName.set(table.name, table.table_type);
|
||||
}
|
||||
const hits = await runDatabaseStructureSearch({
|
||||
connectionId: props.connectionId,
|
||||
databaseType,
|
||||
driverProfile: connection.value?.driver_profile,
|
||||
database: props.database,
|
||||
schema,
|
||||
term: activeKeyword.value,
|
||||
limit,
|
||||
executionId,
|
||||
shouldAbort: () => currentRun !== runId || cancelled.value,
|
||||
listTables: async (taskSchema) => {
|
||||
const listSchema = taskSchema || props.database;
|
||||
const tables = await api.listTables(props.connectionId, props.database, listSchema);
|
||||
return tables.filter(isSearchableTable);
|
||||
},
|
||||
getColumns: (taskSchema, tableName) => api.getColumns(props.connectionId, props.database, taskSchema, tableName),
|
||||
executeQuery: (sql, runExecutionId) => api.executeQuery(props.connectionId, props.database, sql, undefined, runExecutionId),
|
||||
});
|
||||
for (const [index, hit] of hits.entries()) {
|
||||
merged.push({
|
||||
id: `${hit.schema ? `${hit.schema}.` : ""}${hit.tableName}:${hit.columnName}:${index}`,
|
||||
schema: hit.schema ?? schema,
|
||||
tableName: hit.tableName,
|
||||
tableType: hit.tableType ?? tableTypeByName.get(hit.tableName) ?? "UNKNOWN",
|
||||
matchedColumns: [hit.columnName],
|
||||
preview: structureHitPreview(hit),
|
||||
whereInput: "",
|
||||
searchScope: "structure",
|
||||
columnName: hit.columnName,
|
||||
columnComment: hit.columnComment,
|
||||
structureMatchIn: hit.matchedIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (currentRun === runId && !cancelled.value) {
|
||||
results.value = merged;
|
||||
progressDone.value = 1;
|
||||
emitState();
|
||||
}
|
||||
} finally {
|
||||
if (currentExecutionId.value === executionId) currentExecutionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function scanTableRange(currentRun: number, scanAllRemaining: boolean) {
|
||||
const range = databaseSearchBatchRange(nextTableIndex.value, tableTasks.value.length, scanAllRemaining);
|
||||
while (nextTableIndex.value < range.end) {
|
||||
@@ -333,6 +425,8 @@ function openResult(item: DatabaseSearchResultItem) {
|
||||
tableName: item.tableName,
|
||||
tableType: item.tableType,
|
||||
whereInput: item.whereInput,
|
||||
columnName: item.columnName,
|
||||
searchScope: item.searchScope ?? "data",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,9 +484,21 @@ defineExpose({
|
||||
<Label class="text-xs">{{ t("databaseSearch.keyword") }}</Label>
|
||||
<Input ref="keywordInputRef" v-model="keyword" class="h-9" :placeholder="t('databaseSearch.keywordPlaceholder')" :disabled="running" @keydown.enter.prevent="startSearch" />
|
||||
</div>
|
||||
<div v-if="structureSearchAvailable" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("databaseSearch.scope") }}</Label>
|
||||
<Select v-model="searchScope" :disabled="running">
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="data">{{ t("databaseSearch.scopeData") }}</SelectItem>
|
||||
<SelectItem value="structure">{{ t("databaseSearch.scopeStructure") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("databaseSearch.limitPerTable") }}</Label>
|
||||
<Input v-model.number="perTableLimit" class="h-9" type="number" min="1" max="100" :disabled="running" />
|
||||
<Label class="text-xs">{{ isStructureMode ? t("databaseSearch.limitStructure") : t("databaseSearch.limitPerTable") }}</Label>
|
||||
<Input v-model.number="perTableLimit" class="h-9" type="number" min="1" :max="isStructureMode ? 500 : 100" :disabled="running" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<Button class="h-9" :disabled="!canSearch" @click="startSearch">
|
||||
@@ -408,13 +514,13 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground shrink-0">
|
||||
{{ t("databaseSearch.hint") }}
|
||||
{{ isStructureMode ? t("databaseSearch.hintStructure") : t("databaseSearch.hint") }}
|
||||
</div>
|
||||
|
||||
<div v-if="running || progressTotal" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground shrink-0">
|
||||
<span class="tabular-nums">{{ loadingTables ? t("databaseSearch.loadingTables") : progressLabel }}</span>
|
||||
<span class="tabular-nums">{{ loadingTables ? t("databaseSearch.loadingTables") : isStructureMode ? t("databaseSearch.progressStructure") : progressLabel }}</span>
|
||||
<span class="tabular-nums">{{ t("databaseSearch.resultCount", { count: results.length }) }}</span>
|
||||
<div v-if="canContinueSearch" class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<div v-if="canContinueSearch && !isStructureMode" class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 text-xs" @click="continueSearch(false)">
|
||||
{{ t("databaseSearch.continueNextBatch", { count: nextBatchSize }) }}
|
||||
</Button>
|
||||
|
||||
@@ -26,6 +26,8 @@ export type NavigationTarget = {
|
||||
comment?: string | null;
|
||||
columnName?: string;
|
||||
whereInput?: string;
|
||||
/** Database search: open the structure editor instead of a data preview. */
|
||||
searchScope?: "data" | "structure";
|
||||
};
|
||||
|
||||
export interface NavigationOpenOptions {
|
||||
@@ -396,6 +398,11 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
|
||||
|
||||
async function openDatabaseSearchTarget(target: NavigationTarget) {
|
||||
dialogs.showDatabaseSearchDialog.value = false;
|
||||
if (target.searchScope === "structure" && target.tableName) {
|
||||
const tableType = (target.tableType || "").toUpperCase() === "VIEW" ? "view" : "table";
|
||||
queryStore.openTableStructure(target.connectionId, target.database, target.schema, target.tableName, "columns", target.columnName ? { kind: "column", name: target.columnName } : undefined, target.catalog, tableType);
|
||||
return;
|
||||
}
|
||||
await openTableTarget(target);
|
||||
}
|
||||
|
||||
|
||||
@@ -3398,13 +3398,19 @@ export default {
|
||||
open: "Search Database",
|
||||
keyword: "Keyword",
|
||||
keywordPlaceholder: "Enter a user ID, email, order number...",
|
||||
scope: "Search in",
|
||||
scopeData: "Data",
|
||||
scopeStructure: "Structure",
|
||||
limitStructure: "Max results",
|
||||
limitPerTable: "Per table",
|
||||
search: "Search",
|
||||
searching: "Searching",
|
||||
stop: "Stop",
|
||||
hint: "Scans regular tables in the current database or schema. Binary columns are skipped and each table is capped to avoid loading too much data at once.",
|
||||
hintStructure: "Matches column names and comments (or field names where comments are unavailable) using catalog metadata in one query when supported.",
|
||||
loadingTables: "Reading table list...",
|
||||
progress: "Scanned {done}/{total} tables",
|
||||
progressStructure: "Searching structure metadata...",
|
||||
resultCount: "{count} matches",
|
||||
continueNextBatch: "Scan next {count}",
|
||||
scanAllRemaining: "Scan remaining {count}",
|
||||
|
||||
@@ -3300,13 +3300,19 @@ export default withEnglishFallback({
|
||||
open: "Buscar en base de datos",
|
||||
keyword: "Palabra clave",
|
||||
keywordPlaceholder: "Ingresa un ID de usuario, correo, número de orden...",
|
||||
scope: "Buscar en",
|
||||
scopeData: "Datos",
|
||||
scopeStructure: "Estructura",
|
||||
limitStructure: "Máx. resultados",
|
||||
limitPerTable: "Por tabla",
|
||||
search: "Buscar",
|
||||
searching: "Buscando",
|
||||
stop: "Detener",
|
||||
hint: "Analiza las tablas regulares de la base de datos o esquema actual. Se omiten las columnas binarias y cada tabla tiene un límite para evitar cargar demasiados datos a la vez.",
|
||||
hintStructure: "Coincide con nombres y comentarios de columna (o nombres de campo cuando no hay comentarios) mediante metadatos del catálogo en una sola consulta cuando está soportado.",
|
||||
loadingTables: "Leyendo lista de tablas...",
|
||||
progress: "Analizadas {done}/{total} tablas",
|
||||
progressStructure: "Buscando metadatos de estructura...",
|
||||
resultCount: "{count} coincidencias",
|
||||
continueNextBatch: "Analizar las siguientes {count}",
|
||||
scanAllRemaining: "Analizar las {count} restantes",
|
||||
|
||||
@@ -3152,13 +3152,19 @@ export default withEnglishFallback({
|
||||
open: "Cerca nel Database",
|
||||
keyword: "Parola chiave",
|
||||
keywordPlaceholder: "Inserisci un ID utente, un'email, un numero d'ordine...",
|
||||
scope: "Cerca in",
|
||||
scopeData: "Dati",
|
||||
scopeStructure: "Struttura",
|
||||
limitStructure: "Risultati max",
|
||||
limitPerTable: "Per tabella",
|
||||
search: "Cerca",
|
||||
searching: "Ricerca in corso",
|
||||
stop: "Interrompi",
|
||||
hint: "Scansiona le tabelle normali nel database o schema corrente. Le colonne binarie vengono saltate e ciascuna tabella è limitata per evitare il caricamento simultaneo di troppi dati.",
|
||||
hintStructure: "Cerca nei nomi e nei commenti delle colonne (o nei nomi dei campi se i commenti non sono disponibili) tramite metadati del catalogo in un'unica query quando supportato.",
|
||||
loadingTables: "Lettura dell'elenco delle tabelle...",
|
||||
progress: "Scansionate {done}/{total} tabelle",
|
||||
progressStructure: "Ricerca dei metadati di struttura...",
|
||||
resultCount: "{count} corrispondenze",
|
||||
continueNextBatch: "Scansiona le prossime {count}",
|
||||
scanAllRemaining: "Scansiona le {count} rimanenti",
|
||||
|
||||
@@ -3176,13 +3176,19 @@ export default withEnglishFallback({
|
||||
open: "データベース検索",
|
||||
keyword: "キーワード",
|
||||
keywordPlaceholder: "ユーザーID、メールアドレス、注文番号などを入力...",
|
||||
scope: "検索対象",
|
||||
scopeData: "データ",
|
||||
scopeStructure: "構造",
|
||||
limitStructure: "最大件数",
|
||||
limitPerTable: "テーブルあたり",
|
||||
search: "検索",
|
||||
searching: "検索中",
|
||||
stop: "停止",
|
||||
hint: "現在のデータベースまたはスキーマ内の通常テーブルをスキャンします。バイナリ列はスキップされ、一度に過剰なデータを読み込まないよう各テーブルに上限が設定されています。",
|
||||
hintStructure: "サポートされる場合、カタログメタデータを1回のクエリで列名とコメント(コメントがない場合はフィールド名)を照合します。",
|
||||
loadingTables: "テーブル一覧を読み取り中...",
|
||||
progress: "{done}/{total}テーブルをスキャンしました",
|
||||
progressStructure: "構造メタデータを検索中...",
|
||||
resultCount: "{count}件の一致",
|
||||
continueNextBatch: "次の{count}テーブルをスキャン",
|
||||
scanAllRemaining: "残り{count}テーブルをスキャン",
|
||||
|
||||
@@ -3104,13 +3104,19 @@ export default withEnglishFallback({
|
||||
open: "데이터베이스 검색",
|
||||
keyword: "키워드",
|
||||
keywordPlaceholder: "사용자 ID, 이메일, 주문 번호 입력...",
|
||||
scope: "검색 범위",
|
||||
scopeData: "데이터",
|
||||
scopeStructure: "구조",
|
||||
limitStructure: "최대 결과",
|
||||
limitPerTable: "테이블당",
|
||||
search: "검색",
|
||||
searching: "검색 중",
|
||||
stop: "중지",
|
||||
hint: "현재 데이터베이스 또는 스키마의 일반 테이블을 스캔합니다. 바이너리 컬럼은 건너뛰고, 각 테이블은 한 번에 너무 많은 데이터를 불러오지 않도록 제한합니다.",
|
||||
hintStructure: "지원되는 경우 카탈로그 메타데이터로 한 번의 쿼리에서 열 이름과 주석(주석이 없으면 필드 이름)을 검색합니다.",
|
||||
loadingTables: "테이블 목록을 읽는 중...",
|
||||
progress: "테이블 {done}/{total}개 스캔",
|
||||
progressStructure: "구조 메타데이터 검색 중...",
|
||||
resultCount: "{count}개 일치",
|
||||
continueNextBatch: "다음 {count}개 스캔",
|
||||
scanAllRemaining: "남은 {count}개 스캔",
|
||||
|
||||
@@ -3152,13 +3152,19 @@ export default withEnglishFallback({
|
||||
open: "Pesquisar no Banco de Dados",
|
||||
keyword: "Palavra-chave",
|
||||
keywordPlaceholder: "Digite um ID de usuário, e-mail, número de pedido...",
|
||||
scope: "Pesquisar em",
|
||||
scopeData: "Dados",
|
||||
scopeStructure: "Estrutura",
|
||||
limitStructure: "Máx. de resultados",
|
||||
limitPerTable: "Por tabela",
|
||||
search: "Pesquisar",
|
||||
searching: "Pesquisando",
|
||||
stop: "Parar",
|
||||
hint: "Verifica tabelas comuns no banco de dados ou schema atual. Colunas binárias são ignoradas e cada tabela é limitada para evitar carregar dados em excesso de uma só vez.",
|
||||
hintStructure: "Corresponde a nomes e comentários de coluna (ou nomes de campo quando não há comentários) usando metadados do catálogo em uma consulta quando suportado.",
|
||||
loadingTables: "Lendo lista de tabelas...",
|
||||
progress: "{done}/{total} tabelas verificadas",
|
||||
progressStructure: "Pesquisando metadados de estrutura...",
|
||||
resultCount: "{count} correspondências",
|
||||
continueNextBatch: "Verificar próximas {count}",
|
||||
scanAllRemaining: "Verificar {count} restantes",
|
||||
|
||||
@@ -3255,13 +3255,19 @@ export default withEnglishFallback({
|
||||
open: "Поиск по базе данных",
|
||||
keyword: "Ключевое слово",
|
||||
keywordPlaceholder: "Введите ID пользователя, email, номер заказа...",
|
||||
scope: "Искать в",
|
||||
scopeData: "Данные",
|
||||
scopeStructure: "Структура",
|
||||
limitStructure: "Макс. результатов",
|
||||
limitPerTable: "На таблицу",
|
||||
search: "Поиск",
|
||||
searching: "Поиск",
|
||||
stop: "Остановить",
|
||||
hint: "Сканирует обычные таблицы в текущей базе данных или схеме. Двоичные столбцы пропускаются, а объём каждой таблицы ограничивается, чтобы не загружать слишком много данных сразу.",
|
||||
hintStructure: "Сопоставляет имена и комментарии столбцов (или имена полей, если комментариев нет) через метаданные каталога одним запросом, где поддерживается.",
|
||||
loadingTables: "Чтение списка таблиц...",
|
||||
progress: "Просканировано таблиц: {done}/{total}",
|
||||
progressStructure: "Поиск метаданных структуры...",
|
||||
resultCount: "Совпадений: {count}",
|
||||
continueNextBatch: "Просканировать следующие {count}",
|
||||
scanAllRemaining: "Просканировать оставшиеся {count}",
|
||||
|
||||
@@ -3332,13 +3332,19 @@ export default withEnglishFallback({
|
||||
open: "在数据库中查找",
|
||||
keyword: "关键词",
|
||||
keywordPlaceholder: "输入用户 ID、邮箱、订单号等...",
|
||||
scope: "查找范围",
|
||||
scopeData: "数据",
|
||||
scopeStructure: "结构",
|
||||
limitStructure: "最多结果",
|
||||
limitPerTable: "每表最多",
|
||||
search: "开始查找",
|
||||
searching: "查找中",
|
||||
stop: "停止",
|
||||
hint: "会扫描当前数据库或 schema 的普通表;默认跳过二进制列,并限制每张表返回数量,避免大库一次性拉取过多数据。",
|
||||
hintStructure: "在列名与列注释(或无注释引擎的字段名)中匹配;支持的引擎通过元数据目录一次性查询,避免逐表扫描数据。",
|
||||
loadingTables: "正在读取表列表...",
|
||||
progress: "已扫描 {done}/{total} 张表",
|
||||
progressStructure: "正在查找结构元数据...",
|
||||
resultCount: "{count} 条命中",
|
||||
continueNextBatch: "继续扫描下 {count} 张",
|
||||
scanAllRemaining: "扫描剩余 {count} 张",
|
||||
|
||||
@@ -3158,13 +3158,19 @@ export default withEnglishFallback({
|
||||
open: "搜尋資料庫",
|
||||
keyword: "關鍵字",
|
||||
keywordPlaceholder: "輸入使用者 ID、電子郵件、訂單號等……",
|
||||
scope: "搜尋範圍",
|
||||
scopeData: "資料",
|
||||
scopeStructure: "結構",
|
||||
limitStructure: "最多結果",
|
||||
limitPerTable: "每表最多",
|
||||
search: "搜尋",
|
||||
searching: "搜尋中",
|
||||
stop: "停止",
|
||||
hint: "掃描目前資料庫或 schema 中的一般資料表。會跳過二進位欄位,並限制每張資料表的載入量,避免一次載入過多資料。",
|
||||
hintStructure: "在欄位名稱與欄位註解(或無註解引擎的欄位名稱)中比對;支援的引擎透過元資料目錄一次查詢,避免逐表掃描資料。",
|
||||
loadingTables: "正在讀取表清單……",
|
||||
progress: "已掃描 {done}/{total} 張資料表",
|
||||
progressStructure: "正在搜尋結構元資料……",
|
||||
resultCount: "{count} 筆結果",
|
||||
continueNextBatch: "繼續掃描下 {count} 張",
|
||||
scanAllRemaining: "掃描剩餘 {count} 張",
|
||||
|
||||
@@ -298,6 +298,8 @@ export const buildCreateUserSql = forward("buildCreateUserSql");
|
||||
export const buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql");
|
||||
export const buildTableSelectSql = forward("buildTableSelectSql");
|
||||
export const buildDatabaseSearchSql = forward("buildDatabaseSearchSql");
|
||||
export const buildDatabaseStructureSearchSql = forward("buildDatabaseStructureSearchSql");
|
||||
export const supportsDatabaseStructureSearch = forward("supportsDatabaseStructureSearch");
|
||||
export const buildSearchResultWhere = forward("buildSearchResultWhere");
|
||||
export const buildRenameObjectSql = forward("buildRenameObjectSql");
|
||||
export const buildRenameDatabaseSql = forward("buildRenameDatabaseSql");
|
||||
|
||||
@@ -234,6 +234,7 @@ import type { DataGridExtractRequest, DataGridExtractResult } from "@/lib/dataGr
|
||||
import type { BuildCreatePartitionedTableSqlOptions, BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TablePartitionSqlOptions, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
|
||||
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
|
||||
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
|
||||
import type { DatabaseStructureSearchSql, DatabaseStructureSearchSqlOptions } from "@/lib/database/databaseStructureSearch";
|
||||
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
|
||||
import type { BuildViewDdlInput } from "@/lib/table/viewDdl";
|
||||
import type { BuildRenameObjectSqlOptions } from "@/lib/table/objectRenameSql";
|
||||
@@ -1785,6 +1786,14 @@ export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions):
|
||||
return post("/api/query/build-database-search-sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDatabaseStructureSearchSql(options: DatabaseStructureSearchSqlOptions): Promise<DatabaseStructureSearchSql | null> {
|
||||
return post("/api/query/build-database-structure-search-sql", { options });
|
||||
}
|
||||
|
||||
export async function supportsDatabaseStructureSearch(databaseType?: DatabaseType, driverProfile?: string): Promise<boolean> {
|
||||
return post("/api/query/supports-database-structure-search", { databaseType, driverProfile });
|
||||
}
|
||||
|
||||
export async function buildSearchResultWhere(options: SearchResultWhereOptions): Promise<string> {
|
||||
return post("/api/query/build-search-result-where", { options });
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, SchemaSyncSql
|
||||
import type { BuildCreatePartitionedTableSqlOptions, BuildTableOwnerChangeSqlOptions, BuildTableStructureChangeSqlOptions, BuildSingleColumnAlterSqlOptions, SqliteTableStructureChangePreview, TablePartitionSqlOptions, TableStructureChangeSql } from "@/lib/table/tableStructureEditorSql";
|
||||
import type { BuildTableSelectSqlOptions } from "@/lib/table/tableSelectSql";
|
||||
import type { DatabaseSearchSql, DatabaseSearchSqlOptions, SearchResultWhereOptions } from "@/lib/database/databaseSearch";
|
||||
import type { DatabaseStructureSearchSql, DatabaseStructureSearchSqlOptions } from "@/lib/database/databaseStructureSearch";
|
||||
import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceInput } from "@/lib/table/objectSourceEditor";
|
||||
import type { BuildViewDdlInput } from "@/lib/table/viewDdl";
|
||||
import type { BuildRenameObjectSqlOptions } from "@/lib/table/objectRenameSql";
|
||||
@@ -2205,6 +2206,14 @@ export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions):
|
||||
return invoke("build_database_search_sql", { options });
|
||||
}
|
||||
|
||||
export async function buildDatabaseStructureSearchSql(options: DatabaseStructureSearchSqlOptions): Promise<DatabaseStructureSearchSql | null> {
|
||||
return invoke("build_database_structure_search_sql", { options });
|
||||
}
|
||||
|
||||
export async function supportsDatabaseStructureSearch(databaseType?: DatabaseType, driverProfile?: string): Promise<boolean> {
|
||||
return invoke("supports_database_structure_search", { databaseType, driverProfile });
|
||||
}
|
||||
|
||||
export async function buildSearchResultWhere(options: SearchResultWhereOptions): Promise<string> {
|
||||
return invoke("build_search_result_where", { options });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as api from "@/lib/backend/api.ts";
|
||||
import { effectiveDatabaseSearchScope, mapStructureSearchRows, runDatabaseStructureSearch, runSqlCatalogStructureSearch, structureHitPreview, supportsDatabaseStructureSearchFromManifest } from "@/lib/database/databaseStructureSearch";
|
||||
import { supportsDatabaseStructureSearch } from "@/lib/database/databaseStructureSearchCapabilities";
|
||||
|
||||
describe("databaseStructureSearch", () => {
|
||||
it("maps catalog rows into structure hits", () => {
|
||||
const hits = mapStructureSearchRows(
|
||||
["object_schema", "object_name", "column_name", "column_comment"],
|
||||
[
|
||||
["public", "users", "email", "User email"],
|
||||
["public", "users", "id", null],
|
||||
],
|
||||
"mail",
|
||||
);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]?.columnName).toBe("email");
|
||||
expect(hits[0]?.matchedIn).toEqual(["name", "comment"]);
|
||||
});
|
||||
|
||||
it("maps object_type when present in catalog rows", () => {
|
||||
const hits = mapStructureSearchRows(["object_schema", "object_name", "object_type", "column_name", "column_comment"], [["public", "users", "VIEW", "email", null]], "email");
|
||||
expect(hits[0]?.tableType).toBe("VIEW");
|
||||
});
|
||||
|
||||
it("builds previews from structure hits", () => {
|
||||
const preview = structureHitPreview({
|
||||
tableName: "users",
|
||||
columnName: "email",
|
||||
columnComment: "Primary contact email",
|
||||
matchedIn: ["name", "comment"],
|
||||
});
|
||||
expect(preview).toContain("email");
|
||||
expect(preview).toContain("Primary contact email");
|
||||
});
|
||||
|
||||
it("falls back to data scope for unsupported engines", () => {
|
||||
expect(effectiveDatabaseSearchScope("structure", "redis")).toBe("data");
|
||||
expect(supportsDatabaseStructureSearchFromManifest("redis")).toBe(false);
|
||||
expect(supportsDatabaseStructureSearchFromManifest("mysql")).toBe(true);
|
||||
expect(supportsDatabaseStructureSearchFromManifest("dameng")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not claim dropped SQL catalog engines", () => {
|
||||
for (const dbType of ["cassandra", "hive", "exasol", "vertica", "informix", "iris"] as const) {
|
||||
expect(supportsDatabaseStructureSearch(dbType)).toBe(false);
|
||||
expect(effectiveDatabaseSearchScope("structure", dbType)).toBe("data");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the provided execution id for catalog SQL", async () => {
|
||||
const buildSpy = vi.spyOn(api, "buildDatabaseStructureSearchSql").mockResolvedValue({ sql: "SELECT 1" });
|
||||
const executeQuery = vi.fn().mockResolvedValue({
|
||||
columns: ["object_name", "column_name"],
|
||||
rows: [["users", "email"]],
|
||||
});
|
||||
|
||||
await runSqlCatalogStructureSearch({
|
||||
connectionId: "c1",
|
||||
databaseType: "mysql",
|
||||
database: "app",
|
||||
term: "email",
|
||||
executionId: "exec-structure-1",
|
||||
listTables: async () => [],
|
||||
getColumns: async () => [],
|
||||
executeQuery,
|
||||
});
|
||||
|
||||
expect(executeQuery).toHaveBeenCalledWith("SELECT 1", "exec-structure-1");
|
||||
buildSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("aborts document structure search between tables", async () => {
|
||||
let abortBeforeNextTable = false;
|
||||
const getColumns = vi.fn().mockImplementation(async () => {
|
||||
abortBeforeNextTable = true;
|
||||
return [{ name: "email", data_type: "varchar" }];
|
||||
});
|
||||
|
||||
const hits = await runDatabaseStructureSearch({
|
||||
connectionId: "c1",
|
||||
databaseType: "mongodb",
|
||||
database: "app",
|
||||
term: "email",
|
||||
executionId: "exec-doc-1",
|
||||
shouldAbort: () => abortBeforeNextTable,
|
||||
listTables: async () => [
|
||||
{ name: "users", table_type: "VIEW" },
|
||||
{ name: "orders", table_type: "TABLE" },
|
||||
],
|
||||
getColumns,
|
||||
executeQuery: vi.fn(),
|
||||
});
|
||||
|
||||
expect(getColumns).toHaveBeenCalledTimes(1);
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0]?.tableType).toBe("VIEW");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api.ts";
|
||||
import { supportsDatabaseStructureSearch as supportsDatabaseStructureSearchCapability, usesDocumentStructureSearch, usesSqlCatalogStructureSearch } from "@/lib/database/databaseStructureSearchCapabilities";
|
||||
|
||||
export type DatabaseSearchScope = "data" | "structure";
|
||||
|
||||
export interface DatabaseStructureSearchSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
driverProfile?: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
term: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface DatabaseStructureSearchSql {
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export interface DatabaseStructureSearchHit {
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
tableType?: string;
|
||||
columnName: string;
|
||||
columnComment?: string;
|
||||
matchedIn: Array<"name" | "comment">;
|
||||
}
|
||||
|
||||
export interface DatabaseStructureSearchOptions {
|
||||
connectionId: string;
|
||||
databaseType: DatabaseType;
|
||||
driverProfile?: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
term: string;
|
||||
limit?: number;
|
||||
executionId: string;
|
||||
shouldAbort?: () => boolean;
|
||||
listTables: (schema?: string) => Promise<Array<{ name: string; table_type: string }>>;
|
||||
getColumns: (schema: string, tableName: string) => Promise<ColumnInfo[]>;
|
||||
executeQuery: (sql: string, executionId: string) => Promise<{ columns: string[]; rows: unknown[][] }>;
|
||||
}
|
||||
|
||||
function cleanTerm(term: string): string {
|
||||
return term.trim();
|
||||
}
|
||||
|
||||
function termMatches(haystack: string | null | undefined, needle: string): boolean {
|
||||
if (!haystack) return false;
|
||||
return haystack.toLowerCase().includes(needle.toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeRow(columns: string[], row: unknown[]): Record<string, unknown> {
|
||||
const record: Record<string, unknown> = {};
|
||||
columns.forEach((column, index) => {
|
||||
record[column.toLowerCase()] = row[index];
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function mapStructureSearchRows(columns: string[], rows: unknown[][], term: string): DatabaseStructureSearchHit[] {
|
||||
const query = cleanTerm(term);
|
||||
if (!query) return [];
|
||||
const hits: DatabaseStructureSearchHit[] = [];
|
||||
for (const row of rows) {
|
||||
const record = normalizeRow(columns, row);
|
||||
const schema = asString(record.object_schema) || undefined;
|
||||
const tableName = asString(record.object_name);
|
||||
const tableType = asString(record.object_type) || undefined;
|
||||
const columnName = asString(record.column_name);
|
||||
const columnComment = asString(record.column_comment) || undefined;
|
||||
if (!tableName || !columnName) continue;
|
||||
const matchedIn: Array<"name" | "comment"> = [];
|
||||
if (termMatches(columnName, query)) matchedIn.push("name");
|
||||
if (termMatches(columnComment, query)) matchedIn.push("comment");
|
||||
if (!matchedIn.length) continue;
|
||||
hits.push({ schema, tableName, tableType, columnName, columnComment, matchedIn });
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function structureHitPreview(hit: DatabaseStructureSearchHit): string {
|
||||
const parts: string[] = [];
|
||||
if (hit.matchedIn.includes("name")) parts.push(hit.columnName);
|
||||
if (hit.matchedIn.includes("comment") && hit.columnComment) {
|
||||
const comment = hit.columnComment.replace(/\s+/g, " ").trim();
|
||||
parts.push(comment.length > 90 ? `${comment.slice(0, 90)}...` : comment);
|
||||
}
|
||||
return parts.join(" · ") || hit.columnName;
|
||||
}
|
||||
|
||||
export async function buildDatabaseStructureSearchSql(options: DatabaseStructureSearchSqlOptions): Promise<DatabaseStructureSearchSql | null> {
|
||||
return api.buildDatabaseStructureSearchSql(options);
|
||||
}
|
||||
|
||||
export async function supportsDatabaseStructureSearchBackend(databaseType?: DatabaseType, driverProfile?: string): Promise<boolean> {
|
||||
return api.supportsDatabaseStructureSearch(databaseType, driverProfile);
|
||||
}
|
||||
|
||||
export async function runSqlCatalogStructureSearch(options: DatabaseStructureSearchOptions): Promise<DatabaseStructureSearchHit[]> {
|
||||
if (options.shouldAbort?.()) return [];
|
||||
const query = await buildDatabaseStructureSearchSql({
|
||||
databaseType: options.databaseType,
|
||||
driverProfile: options.driverProfile,
|
||||
database: options.database,
|
||||
schema: options.schema,
|
||||
term: options.term,
|
||||
limit: options.limit,
|
||||
});
|
||||
if (!query?.sql || options.shouldAbort?.()) return [];
|
||||
const result = await options.executeQuery(query.sql, options.executionId);
|
||||
if (options.shouldAbort?.()) return [];
|
||||
return mapStructureSearchRows(result.columns, result.rows, options.term);
|
||||
}
|
||||
|
||||
async function runDocumentStructureSearch(options: DatabaseStructureSearchOptions): Promise<DatabaseStructureSearchHit[]> {
|
||||
const query = cleanTerm(options.term);
|
||||
if (!query || options.shouldAbort?.()) return [];
|
||||
const limit = Math.min(5000, Math.max(1, options.limit ?? 200));
|
||||
const hits: DatabaseStructureSearchHit[] = [];
|
||||
const tables = await options.listTables(options.schema);
|
||||
for (const table of tables) {
|
||||
if (options.shouldAbort?.()) return hits;
|
||||
if (hits.length >= limit) break;
|
||||
const schema = options.schema || options.database;
|
||||
let columns: ColumnInfo[] = [];
|
||||
try {
|
||||
columns = await options.getColumns(schema, table.name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const column of columns) {
|
||||
const matchedIn: Array<"name" | "comment"> = [];
|
||||
if (termMatches(column.name, query)) matchedIn.push("name");
|
||||
const comment = column.comment ?? undefined;
|
||||
if (termMatches(comment, query)) matchedIn.push("comment");
|
||||
if (!matchedIn.length) continue;
|
||||
hits.push({
|
||||
schema: options.schema,
|
||||
tableName: table.name,
|
||||
tableType: table.table_type,
|
||||
columnName: column.name,
|
||||
columnComment: comment,
|
||||
matchedIn,
|
||||
});
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export async function runDatabaseStructureSearch(options: DatabaseStructureSearchOptions): Promise<DatabaseStructureSearchHit[]> {
|
||||
if (usesSqlCatalogStructureSearch(options.databaseType)) {
|
||||
return runSqlCatalogStructureSearch(options);
|
||||
}
|
||||
if (usesDocumentStructureSearch(options.databaseType)) {
|
||||
return runDocumentStructureSearch(options);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function effectiveDatabaseSearchScope(requested: DatabaseSearchScope, dbType?: DatabaseType, driverProfile?: string | null): DatabaseSearchScope {
|
||||
if (requested !== "structure") return "data";
|
||||
if (!supportsDatabaseStructureSearchCapability(dbType, driverProfile)) return "data";
|
||||
return "structure";
|
||||
}
|
||||
|
||||
/** Sync guard for UI; mirrors backend catalog support for SQL engines and known document engines. */
|
||||
export function supportsDatabaseStructureSearchFromManifest(dbType?: DatabaseType, driverProfile?: string | null): boolean {
|
||||
return supportsDatabaseStructureSearchCapability(dbType, driverProfile);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
/** Engines with a catalog / metadata one-shot structure search path (issue #10413). */
|
||||
const SQL_STRUCTURE_SEARCH_TYPES = new Set<DatabaseType>([
|
||||
"mysql",
|
||||
"doris",
|
||||
"starrocks",
|
||||
"manticoresearch",
|
||||
"goldendb",
|
||||
"gbase",
|
||||
"postgres",
|
||||
"kingbase",
|
||||
"highgo",
|
||||
"uxdb",
|
||||
"vastbase",
|
||||
"opengauss",
|
||||
"gaussdb",
|
||||
"kwdb",
|
||||
"yashandb",
|
||||
"oscar",
|
||||
"redshift",
|
||||
"sqlite",
|
||||
"turso",
|
||||
"cloudflare-d1",
|
||||
"rqlite",
|
||||
"clickhouse",
|
||||
"oracle",
|
||||
"oceanbase-oracle",
|
||||
"dameng",
|
||||
"sqlserver",
|
||||
"tdengine",
|
||||
"snowflake",
|
||||
"jdbc",
|
||||
]);
|
||||
|
||||
/** Document / mapping engines searched via dedicated metadata APIs (no SQL catalog). */
|
||||
const DOCUMENT_STRUCTURE_SEARCH_TYPES = new Set<DatabaseType>(["mongodb", "elasticsearch", "easysearch", "neo4j", "influxdb", "milvus"]);
|
||||
|
||||
const JDBC_STRUCTURE_PROFILE_MARKERS = ["mysql", "mariadb", "postgres", "kingbase", "oracle", "dameng", "dm", "doris", "selectdb", "starrocks", "gbase8a"];
|
||||
|
||||
export function supportsDatabaseStructureSearch(dbType?: DatabaseType, driverProfile?: string | null): boolean {
|
||||
if (!dbType) return false;
|
||||
if (SQL_STRUCTURE_SEARCH_TYPES.has(dbType) || DOCUMENT_STRUCTURE_SEARCH_TYPES.has(dbType)) {
|
||||
if (dbType !== "jdbc") return true;
|
||||
const profile = (driverProfile ?? "").trim().toLowerCase();
|
||||
return JDBC_STRUCTURE_PROFILE_MARKERS.some((marker) => profile.includes(marker));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function usesSqlCatalogStructureSearch(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && SQL_STRUCTURE_SEARCH_TYPES.has(dbType);
|
||||
}
|
||||
|
||||
export function usesDocumentStructureSearch(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && DOCUMENT_STRUCTURE_SEARCH_TYPES.has(dbType);
|
||||
}
|
||||
@@ -1942,6 +1942,8 @@ export interface TabUiState {
|
||||
page?: Record<string, TabPageUiState>;
|
||||
}
|
||||
|
||||
export type DatabaseSearchScope = "data" | "structure";
|
||||
|
||||
export interface DatabaseSearchResultItem {
|
||||
id: string;
|
||||
schema?: string;
|
||||
@@ -1950,6 +1952,11 @@ export interface DatabaseSearchResultItem {
|
||||
matchedColumns: string[];
|
||||
preview: string;
|
||||
whereInput: string;
|
||||
/** Present for structure-scope hits (column name / comment search). */
|
||||
searchScope?: DatabaseSearchScope;
|
||||
columnName?: string;
|
||||
columnComment?: string;
|
||||
structureMatchIn?: Array<"name" | "comment">;
|
||||
}
|
||||
|
||||
export interface DatabaseSearchTableTask {
|
||||
@@ -1959,6 +1966,7 @@ export interface DatabaseSearchTableTask {
|
||||
|
||||
export interface DatabaseSearchTabState {
|
||||
keyword: string;
|
||||
searchScope?: DatabaseSearchScope;
|
||||
perTableLimit: number;
|
||||
progressDone: number;
|
||||
progressTotal: number;
|
||||
|
||||
@@ -43,6 +43,7 @@ pub use dbx_drivers::database_capabilities;
|
||||
pub use dbx_sql::data_grid_extractors;
|
||||
pub use dbx_sql::data_grid_sql;
|
||||
pub use dbx_sql::database_search_sql;
|
||||
pub use dbx_sql::database_structure_search_sql;
|
||||
pub use dbx_types::database_manifest;
|
||||
pub mod db;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
|
||||
@@ -7041,6 +7041,15 @@ mod tests {
|
||||
fail_once: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn record_snapshot_command(commands: &Mutex<Vec<String>>, statement: &str) {
|
||||
// Infrastructure identity lookup for PostgreSQL notice attribution is
|
||||
// unrelated to manual-transaction snapshot rotation semantics.
|
||||
if statement.starts_with("SELECT pg_backend_pid()") {
|
||||
return;
|
||||
}
|
||||
commands.lock().unwrap().push(statement.to_string());
|
||||
}
|
||||
|
||||
impl deadpool_postgres::Connect for SnapshotTestConnect {
|
||||
fn connect(
|
||||
&self,
|
||||
@@ -7080,12 +7089,7 @@ mod tests {
|
||||
(b'n', Vec::new())
|
||||
}
|
||||
b'E' => {
|
||||
// The driver resolves the connection identity once per
|
||||
// physical connection at checkout; it is driver plumbing,
|
||||
// not part of the session statement history under test.
|
||||
if !statement.starts_with("SELECT pg_backend_pid") {
|
||||
commands.lock().unwrap().push(statement.clone());
|
||||
}
|
||||
record_snapshot_command(&commands, &statement);
|
||||
if fail_once == Some(statement.as_str()) {
|
||||
fail_once = None;
|
||||
(b'E', b"SERROR\0CXX000\0Mtest statement failed\0\0".to_vec())
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseStructureSearchSqlOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub database_type: Option<DatabaseType>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub driver_profile: Option<String>,
|
||||
pub database: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<String>,
|
||||
pub term: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseStructureSearchSql {
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
pub fn supports_database_structure_search(database_type: Option<DatabaseType>, driver_profile: Option<&str>) -> bool {
|
||||
build_database_structure_search_sql(DatabaseStructureSearchSqlOptions {
|
||||
database_type,
|
||||
driver_profile: driver_profile.map(str::to_string),
|
||||
database: "db".to_string(),
|
||||
schema: None,
|
||||
term: "x".to_string(),
|
||||
limit: Some(1),
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn build_database_structure_search_sql(
|
||||
options: DatabaseStructureSearchSqlOptions,
|
||||
) -> Option<DatabaseStructureSearchSql> {
|
||||
let term = clean_term(&options.term);
|
||||
if term.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let limit = clamp_limit(options.limit);
|
||||
let pattern = like_pattern(&term);
|
||||
let pattern_literal = sql_string_literal(&pattern);
|
||||
|
||||
let database_type = options.database_type?;
|
||||
let sql = match database_type {
|
||||
DatabaseType::Mysql
|
||||
| DatabaseType::Doris
|
||||
| DatabaseType::StarRocks
|
||||
| DatabaseType::ManticoreSearch
|
||||
| DatabaseType::Goldendb => {
|
||||
mysql_family_structure_search_sql(&options.database, options.schema.as_deref(), &pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::Postgres
|
||||
| DatabaseType::Kingbase
|
||||
| DatabaseType::Highgo
|
||||
| DatabaseType::Uxdb
|
||||
| DatabaseType::Vastbase
|
||||
| DatabaseType::OpenGauss
|
||||
| DatabaseType::Gaussdb
|
||||
| DatabaseType::Kwdb
|
||||
| DatabaseType::Yashandb
|
||||
| DatabaseType::Oscar => {
|
||||
postgres_family_structure_search_sql(options.schema.as_deref(), &pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::Redshift => {
|
||||
postgres_family_structure_search_sql(options.schema.as_deref(), &pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::Sqlite | DatabaseType::Turso | DatabaseType::CloudflareD1 | DatabaseType::Rqlite => {
|
||||
sqlite_family_structure_search_sql(&pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::ClickHouse => clickhouse_structure_search_sql(&options.database, &pattern_literal, limit),
|
||||
DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Dameng => {
|
||||
oracle_family_structure_search_sql(options.schema.as_deref(), &options.database, &pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::SqlServer => sqlserver_structure_search_sql(options.schema.as_deref(), &pattern_literal, limit),
|
||||
DatabaseType::Tdengine => tdengine_structure_search_sql(&options.database, &pattern_literal, limit),
|
||||
DatabaseType::Snowflake => snowflake_structure_search_sql(options.schema.as_deref(), &pattern_literal, limit),
|
||||
// gbase8s is Informix-compatible (see the table_structure_sql dialects)
|
||||
// and has no information_schema; only MySQL-dialect Gbase 8a takes this path.
|
||||
DatabaseType::Gbase if !driver_profile_is_gbase8s(options.driver_profile.as_deref()) => {
|
||||
gbase_structure_search_sql(&options.database, options.schema.as_deref(), &pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::Jdbc if driver_profile_is_mysql(options.driver_profile.as_deref()) => {
|
||||
mysql_family_structure_search_sql(&options.database, options.schema.as_deref(), &pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::Jdbc if driver_profile_is_postgres(options.driver_profile.as_deref()) => {
|
||||
postgres_family_structure_search_sql(options.schema.as_deref(), &pattern_literal, limit)
|
||||
}
|
||||
DatabaseType::Jdbc if driver_profile_is_oracle(options.driver_profile.as_deref()) => {
|
||||
oracle_family_structure_search_sql(options.schema.as_deref(), &options.database, &pattern_literal, limit)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(DatabaseStructureSearchSql { sql })
|
||||
}
|
||||
|
||||
fn driver_profile_is_mysql(profile: Option<&str>) -> bool {
|
||||
let profile = profile.map(str::trim).unwrap_or("").to_ascii_lowercase();
|
||||
profile.contains("mysql")
|
||||
|| profile.contains("mariadb")
|
||||
|| profile.contains("doris")
|
||||
|| profile.contains("selectdb")
|
||||
|| profile.contains("starrocks")
|
||||
|| profile.contains("gbase8a")
|
||||
}
|
||||
|
||||
fn driver_profile_is_postgres(profile: Option<&str>) -> bool {
|
||||
let profile = profile.map(str::trim).unwrap_or("").to_ascii_lowercase();
|
||||
profile.contains("postgres") || profile.contains("kingbase") || profile.contains("opengauss")
|
||||
}
|
||||
|
||||
fn driver_profile_is_gbase8s(profile: Option<&str>) -> bool {
|
||||
profile.map(str::trim).is_some_and(|profile| profile.to_ascii_lowercase().contains("gbase8s"))
|
||||
}
|
||||
|
||||
fn driver_profile_is_oracle(profile: Option<&str>) -> bool {
|
||||
let profile = profile.map(str::trim).unwrap_or("").to_ascii_lowercase();
|
||||
profile.contains("oracle") || profile.contains("dameng") || profile.contains("dm")
|
||||
}
|
||||
|
||||
fn mysql_family_structure_search_sql(
|
||||
database: &str,
|
||||
schema: Option<&str>,
|
||||
pattern_literal: &str,
|
||||
limit: usize,
|
||||
) -> String {
|
||||
let schema_filter = if let Some(schema) = schema.filter(|value| !value.trim().is_empty()) {
|
||||
format!(" AND TABLE_SCHEMA = {}", sql_string_literal(schema))
|
||||
} else {
|
||||
format!(" AND TABLE_SCHEMA = {}", sql_string_literal(database))
|
||||
};
|
||||
let comment_match = format!(" OR LOWER(COLUMN_COMMENT) LIKE LOWER({}) ESCAPE '~'", pattern_literal);
|
||||
format!(
|
||||
"SELECT TABLE_SCHEMA AS object_schema, TABLE_NAME AS object_name, COLUMN_NAME AS column_name, \
|
||||
COLUMN_COMMENT AS column_comment \
|
||||
FROM information_schema.COLUMNS \
|
||||
WHERE (LOWER(COLUMN_NAME) LIKE LOWER({}) ESCAPE '~'{}) \
|
||||
{schema_filter} \
|
||||
AND TABLE_SCHEMA NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
|
||||
ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION \
|
||||
LIMIT {limit}",
|
||||
pattern_literal, comment_match,
|
||||
)
|
||||
}
|
||||
|
||||
fn postgres_family_structure_search_sql(schema: Option<&str>, pattern_literal: &str, limit: usize) -> String {
|
||||
let schema_filter = if let Some(schema) = schema.filter(|value| !value.trim().is_empty()) {
|
||||
format!(" AND n.nspname = {}", sql_string_literal(schema))
|
||||
} else {
|
||||
" AND n.nspname NOT IN ('pg_catalog', 'information_schema')".to_string()
|
||||
};
|
||||
format!(
|
||||
"SELECT n.nspname AS object_schema, c.relname AS object_name, a.attname AS column_name, \
|
||||
d.description AS column_comment \
|
||||
FROM pg_catalog.pg_attribute a \
|
||||
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
||||
LEFT JOIN pg_catalog.pg_description d ON d.objoid = c.oid AND d.objsubid = a.attnum \
|
||||
WHERE a.attnum > 0 AND NOT a.attisdropped \
|
||||
AND c.relkind IN ('r', 'v', 'm', 'f', 'p') \
|
||||
{schema_filter} \
|
||||
AND (LOWER(a.attname::text) LIKE LOWER({pattern}) ESCAPE '~' \
|
||||
OR LOWER(COALESCE(d.description, '')) LIKE LOWER({pattern}) ESCAPE '~') \
|
||||
ORDER BY n.nspname, c.relname, a.attnum \
|
||||
LIMIT {limit}",
|
||||
schema_filter = schema_filter,
|
||||
pattern = pattern_literal,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn sqlite_family_structure_search_sql(pattern_literal: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"SELECT '' AS object_schema, m.name AS object_name, p.name AS column_name, NULL AS column_comment \
|
||||
FROM sqlite_schema AS m, pragma_table_info(m.name) AS p \
|
||||
WHERE m.type IN ('table', 'view') \
|
||||
AND m.name NOT LIKE 'sqlite_%' \
|
||||
AND LOWER(p.name) LIKE LOWER({pattern}) ESCAPE '~' \
|
||||
ORDER BY m.name, p.cid \
|
||||
LIMIT {limit}",
|
||||
pattern = pattern_literal,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn clickhouse_structure_search_sql(database: &str, pattern_literal: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"SELECT database AS object_schema, table AS object_name, name AS column_name, comment AS column_comment \
|
||||
FROM system.columns \
|
||||
WHERE database = {db} \
|
||||
AND (lower(name) LIKE lower({pattern}) OR lower(comment) LIKE lower({pattern})) \
|
||||
ORDER BY database, table, position \
|
||||
LIMIT {limit}",
|
||||
db = sql_string_literal(database),
|
||||
pattern = pattern_literal,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn oracle_family_structure_search_sql(
|
||||
schema: Option<&str>,
|
||||
database: &str,
|
||||
pattern_literal: &str,
|
||||
limit: usize,
|
||||
) -> String {
|
||||
let owner = schema.filter(|value| !value.trim().is_empty()).unwrap_or(database);
|
||||
let owner_literal = sql_string_literal(owner);
|
||||
let name_expr = "LOWER(c.COLUMN_NAME)";
|
||||
let bounded = format!(
|
||||
"SELECT * FROM ( \
|
||||
SELECT c.OWNER AS object_schema, c.TABLE_NAME AS object_name, c.COLUMN_NAME AS column_name, m.COMMENTS AS column_comment \
|
||||
FROM ALL_TAB_COLUMNS c \
|
||||
LEFT JOIN ALL_COL_COMMENTS m ON c.OWNER = m.OWNER AND c.TABLE_NAME = m.TABLE_NAME AND c.COLUMN_NAME = m.COLUMN_NAME \
|
||||
WHERE c.OWNER = {owner} \
|
||||
AND ({name_expr} LIKE LOWER({pattern}) ESCAPE '~' OR LOWER(COALESCE(m.COMMENTS, '')) LIKE LOWER({pattern}) ESCAPE '~') \
|
||||
ORDER BY c.TABLE_NAME, c.COLUMN_ID) WHERE ROWNUM <= {limit}",
|
||||
owner = owner_literal,
|
||||
name_expr = name_expr,
|
||||
pattern = pattern_literal,
|
||||
limit = limit,
|
||||
);
|
||||
bounded
|
||||
}
|
||||
|
||||
fn sqlserver_structure_search_sql(schema: Option<&str>, pattern_literal: &str, limit: usize) -> String {
|
||||
let schema_filter = if let Some(schema) = schema.filter(|value| !value.trim().is_empty()) {
|
||||
format!(" AND c.TABLE_SCHEMA = {}", sql_string_literal(schema))
|
||||
} else {
|
||||
" AND c.TABLE_SCHEMA NOT IN ('sys', 'INFORMATION_SCHEMA')".to_string()
|
||||
};
|
||||
format!(
|
||||
"SELECT TOP {limit} c.TABLE_SCHEMA AS object_schema, c.TABLE_NAME AS object_name, c.COLUMN_NAME AS column_name, \
|
||||
CAST(ep.value AS NVARCHAR(4000)) AS column_comment \
|
||||
FROM INFORMATION_SCHEMA.COLUMNS c \
|
||||
LEFT JOIN sys.tables t ON t.name = c.TABLE_NAME AND SCHEMA_NAME(t.schema_id) = c.TABLE_SCHEMA \
|
||||
LEFT JOIN sys.columns sc ON sc.object_id = t.object_id AND sc.name = c.COLUMN_NAME \
|
||||
LEFT JOIN sys.extended_properties ep ON ep.major_id = sc.object_id AND ep.minor_id = sc.column_id AND ep.name = 'MS_Description' \
|
||||
WHERE (LOWER(c.COLUMN_NAME) LIKE LOWER({pattern}) ESCAPE '~' \
|
||||
OR LOWER(COALESCE(CAST(ep.value AS NVARCHAR(4000)), '')) LIKE LOWER({pattern}) ESCAPE '~') \
|
||||
{schema_filter} \
|
||||
ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION",
|
||||
limit = limit,
|
||||
pattern = pattern_literal,
|
||||
schema_filter = schema_filter,
|
||||
)
|
||||
}
|
||||
|
||||
fn tdengine_structure_search_sql(database: &str, pattern_literal: &str, limit: usize) -> String {
|
||||
format!(
|
||||
"SELECT db_name AS object_schema, table_name AS object_name, col_name AS column_name, note AS column_comment \
|
||||
FROM information_schema.ins_columns \
|
||||
WHERE db_name = {db} \
|
||||
AND (LOWER(col_name) LIKE LOWER({pattern}) ESCAPE '~' OR LOWER(COALESCE(note, '')) LIKE LOWER({pattern}) ESCAPE '~') \
|
||||
LIMIT {limit}",
|
||||
db = sql_string_literal(database),
|
||||
pattern = pattern_literal,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn snowflake_structure_search_sql(schema: Option<&str>, pattern_literal: &str, limit: usize) -> String {
|
||||
let schema_filter = if let Some(schema) = schema.filter(|value| !value.trim().is_empty()) {
|
||||
format!(" AND table_schema = {}", sql_string_literal(schema))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"SELECT table_schema AS object_schema, table_name AS object_name, column_name AS column_name, comment AS column_comment \
|
||||
FROM information_schema.columns \
|
||||
WHERE (LOWER(column_name) LIKE LOWER({pattern}) ESCAPE '~' OR LOWER(COALESCE(comment, '')) LIKE LOWER({pattern}) ESCAPE '~') \
|
||||
{schema_filter} \
|
||||
ORDER BY table_schema, table_name, ordinal_position \
|
||||
LIMIT {limit}",
|
||||
pattern = pattern_literal,
|
||||
schema_filter = schema_filter,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn gbase_structure_search_sql(database: &str, schema: Option<&str>, pattern_literal: &str, limit: usize) -> String {
|
||||
let database = if database.trim().is_empty() { "gbase" } else { database };
|
||||
mysql_family_structure_search_sql(database, schema, pattern_literal, limit)
|
||||
}
|
||||
|
||||
fn clean_term(term: &str) -> String {
|
||||
term.trim().to_string()
|
||||
}
|
||||
|
||||
fn clamp_limit(limit: Option<usize>) -> usize {
|
||||
limit.unwrap_or(200).clamp(1, 5000)
|
||||
}
|
||||
|
||||
fn sql_string_literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn like_pattern(term: &str) -> String {
|
||||
let mut pattern = String::from("%");
|
||||
for ch in clean_term(term).to_ascii_lowercase().chars() {
|
||||
if matches!(ch, '~' | '%' | '_') {
|
||||
pattern.push('~');
|
||||
}
|
||||
pattern.push(ch);
|
||||
}
|
||||
pattern.push('%');
|
||||
pattern
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mysql_structure_search_uses_information_schema() {
|
||||
let query = build_database_structure_search_sql(DatabaseStructureSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Mysql),
|
||||
driver_profile: None,
|
||||
database: "app".to_string(),
|
||||
schema: None,
|
||||
term: "email".to_string(),
|
||||
limit: Some(50),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(query.sql.contains("information_schema.COLUMNS"));
|
||||
assert!(query.sql.contains("COLUMN_NAME"));
|
||||
assert!(query.sql.contains("COLUMN_COMMENT"));
|
||||
assert!(query.sql.contains("LIMIT 50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dameng_structure_search_uses_oracle_catalog() {
|
||||
let query = build_database_structure_search_sql(DatabaseStructureSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Dameng),
|
||||
driver_profile: None,
|
||||
database: "SYSDBA".to_string(),
|
||||
schema: Some("APP".to_string()),
|
||||
term: "user".to_string(),
|
||||
limit: Some(20),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(query.sql.contains("ALL_TAB_COLUMNS"));
|
||||
assert!(query.sql.contains("ALL_COL_COMMENTS"));
|
||||
assert!(query.sql.contains("ROWNUM <= 20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_engine_returns_none() {
|
||||
assert_eq!(
|
||||
build_database_structure_search_sql(DatabaseStructureSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Redis),
|
||||
driver_profile: None,
|
||||
database: "0".to_string(),
|
||||
schema: None,
|
||||
term: "key".to_string(),
|
||||
limit: None,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_flag_matches_builder() {
|
||||
assert!(supports_database_structure_search(Some(DatabaseType::Mysql), None));
|
||||
assert!(!supports_database_structure_search(Some(DatabaseType::Redis), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_engines_return_none() {
|
||||
for database_type in [
|
||||
DatabaseType::Cassandra,
|
||||
DatabaseType::Informix,
|
||||
DatabaseType::Iris,
|
||||
DatabaseType::Hive,
|
||||
DatabaseType::Spark,
|
||||
DatabaseType::Impala,
|
||||
DatabaseType::Kyuubi,
|
||||
DatabaseType::Exasol,
|
||||
DatabaseType::Vertica,
|
||||
DatabaseType::Db2,
|
||||
DatabaseType::Firebird,
|
||||
DatabaseType::SapHana,
|
||||
DatabaseType::Teradata,
|
||||
DatabaseType::Databend,
|
||||
DatabaseType::Questdb,
|
||||
DatabaseType::PrestoSql,
|
||||
DatabaseType::Trino,
|
||||
] {
|
||||
assert_eq!(
|
||||
build_database_structure_search_sql(DatabaseStructureSearchSqlOptions {
|
||||
database_type: Some(database_type),
|
||||
driver_profile: None,
|
||||
database: "app".to_string(),
|
||||
schema: None,
|
||||
term: "col".to_string(),
|
||||
limit: Some(10),
|
||||
}),
|
||||
None,
|
||||
"expected no SQL for {:?}",
|
||||
database_type
|
||||
);
|
||||
assert!(
|
||||
!supports_database_structure_search(Some(database_type), None),
|
||||
"supports should be false for {:?}",
|
||||
database_type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gbase_search_uses_the_connected_database_and_drops_gbase8s() {
|
||||
let query = build_database_structure_search_sql(DatabaseStructureSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Gbase),
|
||||
driver_profile: None,
|
||||
database: "app".to_string(),
|
||||
schema: None,
|
||||
term: "email".to_string(),
|
||||
limit: Some(10),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
query.sql.contains("TABLE_SCHEMA = 'app'"),
|
||||
"gbase must filter by the connected database, not a fixed literal"
|
||||
);
|
||||
assert!(!query.sql.contains("'gbase'"));
|
||||
|
||||
assert_eq!(
|
||||
build_database_structure_search_sql(DatabaseStructureSearchSqlOptions {
|
||||
database_type: Some(DatabaseType::Gbase),
|
||||
driver_profile: Some("gbase8s".to_string()),
|
||||
database: "app".to_string(),
|
||||
schema: None,
|
||||
term: "email".to_string(),
|
||||
limit: Some(10),
|
||||
}),
|
||||
None,
|
||||
"gbase8s is Informix-compatible and must fall back to data search"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ pub use dbx_types::{database_manifest, models, types};
|
||||
pub mod data_grid_extractors;
|
||||
pub mod data_grid_sql;
|
||||
pub mod database_search_sql;
|
||||
pub mod database_structure_search_sql;
|
||||
pub mod query_result_sql;
|
||||
|
||||
@@ -4,7 +4,9 @@ pub use dbx_sql_core::{
|
||||
dml_preview_sql, mysql_ddl_normalize, mysql_event_sql, object_source_sql, query_execution_sql, sql, sql_analysis,
|
||||
sql_diagnostics, sql_editability, sql_error_position, sql_risk, sqlserver_temporal, tdsql_mysql, value_literals,
|
||||
};
|
||||
pub use dbx_sql_data::{data_grid_extractors, data_grid_sql, database_search_sql, query_result_sql};
|
||||
pub use dbx_sql_data::{
|
||||
data_grid_extractors, data_grid_sql, database_search_sql, database_structure_search_sql, query_result_sql,
|
||||
};
|
||||
pub use dbx_sql_dialect::{dml_binding, postgres_index_key, sql_dialect};
|
||||
pub use dbx_sql_schema::{db_admin_sql, schema_diff, sql_parser, table_structure_sql};
|
||||
pub use dbx_types::{database_manifest, models, types};
|
||||
|
||||
@@ -824,6 +824,8 @@ async fn serve() -> Result<(), String> {
|
||||
.route("/query/build-create-user-sql", post(routes::query::build_create_user_sql))
|
||||
.route("/query/build-table-select-sql", post(routes::query::build_table_select_sql))
|
||||
.route("/query/build-database-search-sql", post(routes::query::build_database_search_sql))
|
||||
.route("/query/build-database-structure-search-sql", post(routes::query::build_database_structure_search_sql))
|
||||
.route("/query/supports-database-structure-search", post(routes::query::supports_database_structure_search))
|
||||
.route("/query/build-search-result-where", post(routes::query::build_search_result_where))
|
||||
.route("/query/build-rename-object-sql", post(routes::query::build_rename_object_sql))
|
||||
.route("/query/build-rename-database-sql", post(routes::query::build_rename_database_sql))
|
||||
|
||||
@@ -144,6 +144,19 @@ pub struct BuildDatabaseSearchSqlRequest {
|
||||
pub options: dbx_core::database_search_sql::DatabaseSearchSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildDatabaseStructureSearchSqlRequest {
|
||||
pub options: dbx_core::database_structure_search_sql::DatabaseStructureSearchSqlOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SupportsDatabaseStructureSearchRequest {
|
||||
pub database_type: Option<dbx_core::models::connection::DatabaseType>,
|
||||
pub driver_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildSearchResultWhereRequest {
|
||||
@@ -962,6 +975,19 @@ pub async fn build_database_search_sql(
|
||||
Json(dbx_core::database_search_sql::build_database_search_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn build_database_structure_search_sql(
|
||||
Json(req): Json<BuildDatabaseStructureSearchSqlRequest>,
|
||||
) -> Json<Option<dbx_core::database_structure_search_sql::DatabaseStructureSearchSql>> {
|
||||
Json(dbx_core::database_structure_search_sql::build_database_structure_search_sql(req.options))
|
||||
}
|
||||
|
||||
pub async fn supports_database_structure_search(Json(req): Json<SupportsDatabaseStructureSearchRequest>) -> Json<bool> {
|
||||
Json(dbx_core::database_structure_search_sql::supports_database_structure_search(
|
||||
req.database_type,
|
||||
req.driver_profile.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn build_search_result_where(Json(req): Json<BuildSearchResultWhereRequest>) -> Json<String> {
|
||||
Json(dbx_core::database_search_sql::build_search_result_where(req.options))
|
||||
}
|
||||
|
||||
+12
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
|
||||
import { findMatchedSearchColumns, isNumericSearchColumn, isTextSearchColumn } from "../../apps/desktop/src/lib/database/databaseSearch.ts";
|
||||
import { DATABASE_SEARCH_TABLE_BATCH_SIZE, databaseSearchBatchRange, databaseSearchNextBatchSize } from "../../apps/desktop/src/lib/database/databaseSearchBatch.ts";
|
||||
import { effectiveDatabaseSearchScope, mapStructureSearchRows } from "../../apps/desktop/src/lib/database/databaseStructureSearch.ts";
|
||||
|
||||
function col(name: string, dataType: string, primary = false): ColumnInfo {
|
||||
return {
|
||||
@@ -36,6 +37,17 @@ test("treats 200 tables as a resumable batch instead of a search cap", () => {
|
||||
assert.equal(databaseSearchNextBatchSize(400, 450), 50);
|
||||
});
|
||||
|
||||
test("structure search scope falls back on unsupported engines", () => {
|
||||
assert.equal(effectiveDatabaseSearchScope("structure", "redis"), "data");
|
||||
assert.equal(effectiveDatabaseSearchScope("structure", "mysql"), "structure");
|
||||
});
|
||||
|
||||
test("maps structure catalog rows", () => {
|
||||
const hits = mapStructureSearchRows(["object_name", "column_name", "column_comment"], [["orders", "order_no", ""]], "order");
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0]?.tableName, "orders");
|
||||
});
|
||||
|
||||
test("can scan every remaining table without exceeding the discovered table list", () => {
|
||||
assert.deepEqual(databaseSearchBatchRange(200, 450, true), { start: 200, end: 450 });
|
||||
assert.deepEqual(databaseSearchBatchRange(999, 450, true), { start: 450, end: 450 });
|
||||
|
||||
@@ -625,6 +625,24 @@ pub fn build_database_search_sql(
|
||||
Ok(dbx_core::database_search_sql::build_database_search_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_database_structure_search_sql(
|
||||
options: dbx_core::database_structure_search_sql::DatabaseStructureSearchSqlOptions,
|
||||
) -> Result<Option<dbx_core::database_structure_search_sql::DatabaseStructureSearchSql>, String> {
|
||||
Ok(dbx_core::database_structure_search_sql::build_database_structure_search_sql(options))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn supports_database_structure_search(
|
||||
database_type: Option<dbx_core::models::connection::DatabaseType>,
|
||||
driver_profile: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(dbx_core::database_structure_search_sql::supports_database_structure_search(
|
||||
database_type,
|
||||
driver_profile.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn build_search_result_where(
|
||||
options: dbx_core::database_search_sql::SearchResultWhereOptions,
|
||||
|
||||
@@ -2134,6 +2134,8 @@ pub fn run() {
|
||||
commands::query::build_dropped_file_preview_sql,
|
||||
commands::query::build_table_select_sql,
|
||||
commands::query::build_database_search_sql,
|
||||
commands::query::build_database_structure_search_sql,
|
||||
commands::query::supports_database_structure_search,
|
||||
commands::query::build_search_result_where,
|
||||
commands::query::build_rename_object_sql,
|
||||
commands::query::build_rename_database_sql,
|
||||
|
||||
Reference in New Issue
Block a user