feat(search): add database structure search by column name and comment

This commit is contained in:
一克猫
2026-10-11 15:24:19 +08:00
committed by GitHub
parent 7b1c29f3cf
commit e7d9bd4ea8
28 changed files with 1054 additions and 15 deletions
@@ -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);
}
+6
View File
@@ -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}",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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}テーブルをスキャン",
+6
View File
@@ -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}개 스캔",
+6
View File
@@ -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",
+6
View File
@@ -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}",
+6
View File
@@ -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} 张",
+6
View File
@@ -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} 張",
+2
View File
@@ -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");
+9
View File
@@ -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 });
}
+9
View File
@@ -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);
}
+8
View File
@@ -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;
+1
View File
@@ -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")]
+10 -6
View File
@@ -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"
);
}
}
+1
View File
@@ -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;
+3 -1
View File
@@ -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};
+2
View File
@@ -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))
+26
View File
@@ -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
View File
@@ -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 });
+18
View File
@@ -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,
+2
View File
@@ -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,