mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:08:16 +00:00
Merge pull request #78 from SuLea-IT/codex/issue-76-database-search
[codex] Add database-wide search
This commit is contained in:
+66
@@ -43,6 +43,7 @@ const TableStructureEditorDialog = defineAsyncComponent(() => import("@/componen
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
|
||||
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -133,6 +134,7 @@ const showDiagramDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
const showStructureEditorDialog = ref(false);
|
||||
const showFieldLineageDialog = ref(false);
|
||||
const showDatabaseSearchDialog = ref(false);
|
||||
const showConfigPassphraseDialog = ref(false);
|
||||
const configPassphraseMode = ref<"export" | "import">("export");
|
||||
const configPassphraseError = ref("");
|
||||
@@ -160,6 +162,9 @@ const lineagePrefillDatabase = ref("");
|
||||
const lineagePrefillSchema = ref("");
|
||||
const lineagePrefillTable = ref("");
|
||||
const lineagePrefillColumn = ref("");
|
||||
const databaseSearchPrefillConnectionId = ref("");
|
||||
const databaseSearchPrefillDatabase = ref("");
|
||||
const databaseSearchPrefillSchema = ref("");
|
||||
type LineageNavigationTarget = {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
@@ -167,6 +172,13 @@ type LineageNavigationTarget = {
|
||||
tableName: string;
|
||||
columnName?: string;
|
||||
};
|
||||
type DatabaseSearchNavigationTarget = {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
whereInput?: string;
|
||||
};
|
||||
const databaseOptions = ref<Record<string, string[]>>({});
|
||||
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
|
||||
const checkingUpdates = ref(false);
|
||||
@@ -266,6 +278,16 @@ watch(() => connectionStore.fieldLineageSource, (v) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => connectionStore.databaseSearchSource, (v) => {
|
||||
if (v) {
|
||||
databaseSearchPrefillConnectionId.value = v.connectionId;
|
||||
databaseSearchPrefillDatabase.value = v.database;
|
||||
databaseSearchPrefillSchema.value = v.schema ?? "";
|
||||
showDatabaseSearchDialog.value = true;
|
||||
connectionStore.databaseSearchSource = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function onStructureEditorSaved() {
|
||||
const tab = activeTab.value;
|
||||
if (tab?.mode === "data" && tab.tableMeta?.tableName === structurePrefillTable.value) {
|
||||
@@ -324,6 +346,43 @@ async function openLineageTarget(target: LineageNavigationTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openDatabaseSearchTarget(target: DatabaseSearchNavigationTarget) {
|
||||
showDatabaseSearchDialog.value = false;
|
||||
connectionStore.activeConnectionId = target.connectionId;
|
||||
const config = connectionStore.getConfig(target.connectionId);
|
||||
const tabTitle = target.schema ? `${target.schema}.${target.tableName}` : target.tableName;
|
||||
const tabId = queryStore.createTab(target.connectionId, target.database, tabTitle, "data");
|
||||
queryStore.setExecuting(tabId, true);
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
|
||||
const querySchema = target.schema || target.database;
|
||||
const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName);
|
||||
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
primaryKeys,
|
||||
whereInput: target.whereInput,
|
||||
});
|
||||
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
} catch (e: any) {
|
||||
queryStore.setErrorResult(tabId, e);
|
||||
}
|
||||
}
|
||||
|
||||
function onExportClick() {
|
||||
configPassphraseMode.value = "export";
|
||||
configPassphraseError.value = "";
|
||||
@@ -1635,6 +1694,13 @@ async function setupFileDrop() {
|
||||
:prefill-column="lineagePrefillColumn"
|
||||
@open-target="openLineageTarget"
|
||||
/>
|
||||
<DatabaseSearchDialog
|
||||
v-model:open="showDatabaseSearchDialog"
|
||||
:prefill-connection-id="databaseSearchPrefillConnectionId"
|
||||
:prefill-database="databaseSearchPrefillDatabase"
|
||||
:prefill-schema="databaseSearchPrefillSchema"
|
||||
@open-target="openDatabaseSearchTarget"
|
||||
/>
|
||||
<ConfigPassphraseDialog
|
||||
v-model:open="showConfigPassphraseDialog"
|
||||
:mode="configPassphraseMode"
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertCircle, Loader2, Search, Square, Table2 } from "lucide-vue-next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/tauri";
|
||||
import {
|
||||
buildDatabaseSearchSql,
|
||||
buildSearchResultWhere,
|
||||
findMatchedSearchColumns,
|
||||
} from "@/lib/databaseSearch";
|
||||
import type { DatabaseType, TableInfo } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
prefillConnectionId: string;
|
||||
prefillDatabase: string;
|
||||
prefillSchema?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
"open-target": [value: {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
whereInput?: string;
|
||||
}];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const dialogOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit("update:open", value),
|
||||
});
|
||||
|
||||
type SearchResultItem = {
|
||||
id: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
matchedColumns: string[];
|
||||
preview: string;
|
||||
whereInput: string;
|
||||
};
|
||||
|
||||
type SearchTableTask = {
|
||||
schema?: string;
|
||||
table: TableInfo;
|
||||
};
|
||||
|
||||
const SCHEMA_AWARE_TYPES = new Set<DatabaseType>(["postgres", "sqlserver", "oracle", "redshift"]);
|
||||
const SYSTEM_SCHEMAS = new Set(["information_schema", "pg_catalog", "sys", "system", "mysql", "performance_schema", "xdb", "outln", "dbsnmp"]);
|
||||
const MAX_TABLES = 200;
|
||||
|
||||
const keyword = ref("");
|
||||
const perTableLimit = ref(20);
|
||||
const running = ref(false);
|
||||
const cancelled = ref(false);
|
||||
const loadingTables = ref(false);
|
||||
const progressDone = ref(0);
|
||||
const progressTotal = ref(0);
|
||||
const results = ref<SearchResultItem[]>([]);
|
||||
const tableErrors = ref<Array<{ tableName: string; message: string }>>([]);
|
||||
const generalError = ref("");
|
||||
const limitedTables = ref(false);
|
||||
const currentExecutionId = ref("");
|
||||
let runId = 0;
|
||||
|
||||
const connection = computed(() => props.prefillConnectionId ? connectionStore.getConfig(props.prefillConnectionId) : undefined);
|
||||
const scopeLabel = computed(() =>
|
||||
[connection.value?.name, props.prefillDatabase, props.prefillSchema].filter(Boolean).join(" / "),
|
||||
);
|
||||
const canSearch = computed(() =>
|
||||
Boolean(props.prefillConnectionId && props.prefillDatabase && keyword.value.trim() && !running.value),
|
||||
);
|
||||
const progressLabel = computed(() =>
|
||||
t("databaseSearch.progress", { done: progressDone.value, total: progressTotal.value }),
|
||||
);
|
||||
|
||||
watch(dialogOpen, (open) => {
|
||||
if (open) {
|
||||
resetSearchState();
|
||||
} else {
|
||||
stopSearch();
|
||||
}
|
||||
});
|
||||
|
||||
function resetSearchState() {
|
||||
running.value = false;
|
||||
cancelled.value = false;
|
||||
loadingTables.value = false;
|
||||
progressDone.value = 0;
|
||||
progressTotal.value = 0;
|
||||
results.value = [];
|
||||
tableErrors.value = [];
|
||||
generalError.value = "";
|
||||
limitedTables.value = false;
|
||||
currentExecutionId.value = "";
|
||||
}
|
||||
|
||||
function isSchemaAware(databaseType?: DatabaseType): boolean {
|
||||
return !!databaseType && SCHEMA_AWARE_TYPES.has(databaseType);
|
||||
}
|
||||
|
||||
function isSearchableTable(table: TableInfo): boolean {
|
||||
const type = table.table_type.toUpperCase();
|
||||
return type.includes("TABLE") && !type.includes("VIEW");
|
||||
}
|
||||
|
||||
function filterSearchSchemas(schemas: string[]): string[] {
|
||||
return schemas.filter((schema) => !SYSTEM_SCHEMAS.has(schema.toLowerCase()));
|
||||
}
|
||||
|
||||
function makeExecutionId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return `database-search-${crypto.randomUUID()}`;
|
||||
return `database-search-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function valuePreview(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
const text = String(value).replace(/\s+/g, " ").trim();
|
||||
return text.length > 90 ? `${text.slice(0, 90)}...` : text;
|
||||
}
|
||||
|
||||
function rowPreview(columns: string[], row: unknown[], matchedColumns: string[]): string {
|
||||
const matched = matchedColumns
|
||||
.slice(0, 3)
|
||||
.map((column) => {
|
||||
const index = columns.findIndex((name) => name.toLowerCase() === column.toLowerCase());
|
||||
return index >= 0 ? `${column}: ${valuePreview(row[index])}` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (matched.length) return matched.join(" · ");
|
||||
|
||||
return columns
|
||||
.slice(0, 3)
|
||||
.map((column, index) => `${column}: ${valuePreview(row[index])}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
async function listSearchTables(): Promise<SearchTableTask[]> {
|
||||
if (!connection.value || !props.prefillConnectionId || !props.prefillDatabase) return [];
|
||||
const databaseType = connection.value.db_type;
|
||||
const schemaNames = props.prefillSchema
|
||||
? [props.prefillSchema]
|
||||
: isSchemaAware(databaseType)
|
||||
? filterSearchSchemas(await api.listSchemas(props.prefillConnectionId, props.prefillDatabase))
|
||||
: [props.prefillDatabase];
|
||||
|
||||
const tasks: SearchTableTask[] = [];
|
||||
for (const schema of schemaNames) {
|
||||
const tables = await api.listTables(props.prefillConnectionId, props.prefillDatabase, schema);
|
||||
for (const table of tables.filter(isSearchableTable)) {
|
||||
tasks.push({ schema, table });
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length > MAX_TABLES) {
|
||||
limitedTables.value = true;
|
||||
return tasks.slice(0, MAX_TABLES);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function startSearch() {
|
||||
if (!canSearch.value || !connection.value) return;
|
||||
const currentRun = ++runId;
|
||||
resetSearchState();
|
||||
running.value = true;
|
||||
loadingTables.value = true;
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.prefillConnectionId);
|
||||
const tasks = await listSearchTables();
|
||||
if (currentRun !== runId || cancelled.value) return;
|
||||
progressTotal.value = tasks.length;
|
||||
loadingTables.value = false;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (currentRun !== runId || cancelled.value) break;
|
||||
await searchTable(task, connection.value.db_type, currentRun);
|
||||
progressDone.value += 1;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled.value) generalError.value = error?.message || String(error);
|
||||
} finally {
|
||||
if (currentRun === runId) {
|
||||
running.value = false;
|
||||
loadingTables.value = false;
|
||||
currentExecutionId.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function searchTable(task: SearchTableTask, databaseType: DatabaseType, currentRun: number) {
|
||||
const tableLabel = task.schema ? `${task.schema}.${task.table.name}` : task.table.name;
|
||||
try {
|
||||
const schema = task.schema || props.prefillDatabase;
|
||||
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, task.table.name);
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType,
|
||||
schema: task.schema,
|
||||
tableName: task.table.name,
|
||||
columns,
|
||||
term: keyword.value,
|
||||
limit: perTableLimit.value,
|
||||
});
|
||||
if (!query || currentRun !== runId || cancelled.value) return;
|
||||
|
||||
const executionId = makeExecutionId();
|
||||
currentExecutionId.value = executionId;
|
||||
const result = await api.executeQuery(props.prefillConnectionId, props.prefillDatabase, query.sql, executionId);
|
||||
if (currentExecutionId.value === executionId) currentExecutionId.value = "";
|
||||
if (currentRun !== runId || cancelled.value) return;
|
||||
|
||||
result.rows.forEach((row, rowIndex) => {
|
||||
const matchedColumns = findMatchedSearchColumns(result.columns, row, columns, keyword.value);
|
||||
const whereInput = buildSearchResultWhere({
|
||||
databaseType,
|
||||
columns,
|
||||
resultColumns: result.columns,
|
||||
row,
|
||||
matchedColumns,
|
||||
});
|
||||
results.value.push({
|
||||
id: `${tableLabel}:${rowIndex}:${results.value.length}`,
|
||||
schema: task.schema,
|
||||
tableName: task.table.name,
|
||||
matchedColumns,
|
||||
preview: rowPreview(result.columns, row, matchedColumns),
|
||||
whereInput,
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (!cancelled.value) {
|
||||
tableErrors.value.push({ tableName: tableLabel, message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSearch() {
|
||||
cancelled.value = true;
|
||||
runId++;
|
||||
running.value = false;
|
||||
loadingTables.value = false;
|
||||
const executionId = currentExecutionId.value;
|
||||
currentExecutionId.value = "";
|
||||
if (executionId) {
|
||||
try {
|
||||
await api.cancelQuery(executionId);
|
||||
} catch {
|
||||
// Best effort; some drivers may finish before cancellation arrives.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openResult(item: SearchResultItem) {
|
||||
emit("open-target", {
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase,
|
||||
schema: item.schema,
|
||||
tableName: item.tableName,
|
||||
whereInput: item.whereInput,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogScrollContent class="max-w-4xl gap-0 p-0">
|
||||
<DialogHeader class="border-b px-5 py-4">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Search class="h-5 w-5" />
|
||||
{{ t('databaseSearch.title') }}
|
||||
</DialogTitle>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ scopeLabel }}</span>
|
||||
<Badge v-if="props.prefillSchema" variant="secondary">{{ props.prefillSchema }}</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_9rem_auto]">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t('databaseSearch.keyword') }}</Label>
|
||||
<Input
|
||||
v-model="keyword"
|
||||
class="h-9"
|
||||
:placeholder="t('databaseSearch.keywordPlaceholder')"
|
||||
:disabled="running"
|
||||
@keydown.enter.prevent="startSearch"
|
||||
/>
|
||||
</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" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<Button class="h-9" :disabled="!canSearch" @click="startSearch">
|
||||
<Loader2 v-if="running" class="h-4 w-4 animate-spin" />
|
||||
<Search v-else class="h-4 w-4" />
|
||||
{{ running ? t('databaseSearch.searching') : t('databaseSearch.search') }}
|
||||
</Button>
|
||||
<Button v-if="running" variant="destructive" class="h-9" @click="stopSearch">
|
||||
<Square class="h-4 w-4 fill-current" />
|
||||
{{ t('databaseSearch.stop') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{{ t('databaseSearch.hint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="running || progressTotal" class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ loadingTables ? t('databaseSearch.loadingTables') : progressLabel }}</span>
|
||||
<span>{{ t('databaseSearch.resultCount', { count: results.length }) }}</span>
|
||||
<span v-if="limitedTables" class="text-amber-600">{{ t('databaseSearch.limitedTables', { count: 200 }) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="generalError" class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle class="mt-0.5 h-4 w-4" />
|
||||
<span>{{ generalError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium">{{ t('databaseSearch.results') }}</div>
|
||||
<Badge variant="outline">{{ results.length }}</Badge>
|
||||
</div>
|
||||
<div v-if="results.length" class="max-h-[360px] space-y-2 overflow-auto pr-1">
|
||||
<button
|
||||
v-for="item in results"
|
||||
:key="item.id"
|
||||
class="flex w-full items-start gap-3 rounded-md border bg-background px-3 py-2 text-left transition-colors hover:bg-muted/40"
|
||||
@click="openResult(item)"
|
||||
>
|
||||
<Table2 class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate font-medium">{{ item.schema ? `${item.schema}.${item.tableName}` : item.tableName }}</span>
|
||||
<Badge v-for="column in item.matchedColumns" :key="column" variant="secondary">{{ column }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate text-xs text-muted-foreground">{{ item.preview }}</div>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-primary">{{ t('databaseSearch.openResult') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="rounded-md border border-dashed py-10 text-center text-sm text-muted-foreground">
|
||||
{{ running ? t('databaseSearch.waiting') : t('databaseSearch.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tableErrors.length" class="space-y-2">
|
||||
<div class="text-sm font-medium text-muted-foreground">{{ t('databaseSearch.errors') }}</div>
|
||||
<div class="max-h-24 space-y-1 overflow-auto rounded-md border bg-muted/20 p-2 text-xs text-muted-foreground">
|
||||
<div v-for="error in tableErrors" :key="`${error.tableName}:${error.message}`">
|
||||
<span class="font-medium text-foreground">{{ error.tableName }}</span>: {{ error.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="border-t px-5 py-3">
|
||||
<Button variant="outline" @click="dialogOpen = false">{{ t('common.close') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
|
||||
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
|
||||
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network, FileUp, PencilRuler,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network, FileUp, PencilRuler, Search,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
@@ -44,6 +44,7 @@ const props = defineProps<{
|
||||
|
||||
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
|
||||
const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
const databaseSearchSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift", "duckdb", "clickhouse"]);
|
||||
const tableImportSupportedTypes = new Set(["mysql", "postgres", "sqlite", "duckdb", "clickhouse", "sqlserver", "oracle", "doris", "starrocks", "redshift"]);
|
||||
const tableStructureSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver"]);
|
||||
const fieldLineageSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]);
|
||||
@@ -513,6 +514,16 @@ function openDiagram() {
|
||||
};
|
||||
}
|
||||
|
||||
function openDatabaseSearch() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
connectionStore.databaseSearchSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: node.type === "schema" ? node.schema : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function openTableImport() {
|
||||
const node = props.node;
|
||||
if (node.type !== "table" || !node.connectionId || !node.database) return;
|
||||
@@ -558,6 +569,10 @@ const canOpenDiagram = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return !!props.node.database && !!config && diagramSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const canOpenDatabaseSearch = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return !!props.node.database && !!config && databaseSearchSupportedTypes.has(config.db_type);
|
||||
});
|
||||
const canOpenTableImport = computed(() => {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return props.node.type === "table" && !!props.node.database && !!config && tableImportSupportedTypes.has(config.db_type);
|
||||
@@ -711,6 +726,9 @@ async function showMore() {
|
||||
<ContextMenuItem v-if="canOpenDiagram" @click="openDiagram">
|
||||
<Network class="w-4 h-4" /> {{ t('diagram.open') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="canOpenDatabaseSearch" @click="openDatabaseSearch">
|
||||
<Search class="w-4 h-4" /> {{ t('databaseSearch.open') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -233,6 +233,26 @@ export default {
|
||||
cancel: "Cancel",
|
||||
refresh: "Analyze Again",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "Search Database",
|
||||
open: "Search Database",
|
||||
keyword: "Keyword",
|
||||
keywordPlaceholder: "Enter a user ID, email, order number...",
|
||||
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.",
|
||||
loadingTables: "Reading table list...",
|
||||
progress: "Scanned {done}/{total} tables",
|
||||
resultCount: "{count} matches",
|
||||
limitedTables: "Large schema detected; only the first {count} tables are scanned",
|
||||
results: "Results",
|
||||
openResult: "Open",
|
||||
waiting: "Waiting for matches...",
|
||||
noResults: "No matches yet",
|
||||
errors: "Some tables failed",
|
||||
},
|
||||
configExport: {
|
||||
passphraseTitle: "Encrypt & Export",
|
||||
passphraseImportTitle: "Decrypt & Import",
|
||||
|
||||
@@ -233,6 +233,26 @@ export default {
|
||||
cancel: "取消读取",
|
||||
refresh: "重新分析",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "在数据库中查找",
|
||||
open: "在数据库中查找",
|
||||
keyword: "关键词",
|
||||
keywordPlaceholder: "输入用户 ID、邮箱、订单号等...",
|
||||
limitPerTable: "每表最多",
|
||||
search: "开始查找",
|
||||
searching: "查找中",
|
||||
stop: "停止",
|
||||
hint: "会扫描当前数据库或 schema 的普通表;默认跳过二进制列,并限制每张表返回数量,避免大库一次性拉取过多数据。",
|
||||
loadingTables: "正在读取表列表...",
|
||||
progress: "已扫描 {done}/{total} 张表",
|
||||
resultCount: "{count} 条命中",
|
||||
limitedTables: "表数量较多,本次仅扫描前 {count} 张表",
|
||||
results: "查找结果",
|
||||
openResult: "打开",
|
||||
waiting: "等待命中结果...",
|
||||
noResults: "暂无命中结果",
|
||||
errors: "部分表查找失败",
|
||||
},
|
||||
configExport: {
|
||||
passphraseTitle: "加密导出",
|
||||
passphraseImportTitle: "解密导入",
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { ColumnInfo, DatabaseType } from "../types/database.ts";
|
||||
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
|
||||
|
||||
export interface DatabaseSearchSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columns: ColumnInfo[];
|
||||
term: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface DatabaseSearchSql {
|
||||
sql: string;
|
||||
searchableColumns: string[];
|
||||
}
|
||||
|
||||
export interface SearchResultWhereOptions {
|
||||
databaseType?: DatabaseType;
|
||||
columns: ColumnInfo[];
|
||||
resultColumns: string[];
|
||||
row: unknown[];
|
||||
matchedColumns?: string[];
|
||||
}
|
||||
|
||||
const TEXT_TYPES = [
|
||||
"char",
|
||||
"text",
|
||||
"clob",
|
||||
"varchar",
|
||||
"nvarchar",
|
||||
"nchar",
|
||||
"uuid",
|
||||
"uniqueidentifier",
|
||||
"enum",
|
||||
];
|
||||
|
||||
const NUMBER_TYPES = [
|
||||
"int",
|
||||
"serial",
|
||||
"number",
|
||||
"numeric",
|
||||
"decimal",
|
||||
"float",
|
||||
"double",
|
||||
"real",
|
||||
"money",
|
||||
];
|
||||
|
||||
const SKIPPED_TYPES = ["blob", "binary", "bytea", "image", "geometry", "geography"];
|
||||
|
||||
function normalizedType(column: ColumnInfo): string {
|
||||
return column.data_type.toLowerCase();
|
||||
}
|
||||
|
||||
export function isTextSearchColumn(column: ColumnInfo): boolean {
|
||||
const type = normalizedType(column);
|
||||
if (SKIPPED_TYPES.some((skipped) => type.includes(skipped))) return false;
|
||||
return TEXT_TYPES.some((textType) => type.includes(textType));
|
||||
}
|
||||
|
||||
export function isNumericSearchColumn(column: ColumnInfo): boolean {
|
||||
const type = normalizedType(column);
|
||||
if (SKIPPED_TYPES.some((skipped) => type.includes(skipped))) return false;
|
||||
return NUMBER_TYPES.some((numberType) => type.includes(numberType));
|
||||
}
|
||||
|
||||
function cleanTerm(term: string): string {
|
||||
return term.trim();
|
||||
}
|
||||
|
||||
function clampLimit(limit?: number): number {
|
||||
if (!Number.isFinite(limit)) return 20;
|
||||
return Math.min(100, Math.max(1, Math.trunc(limit ?? 20)));
|
||||
}
|
||||
|
||||
function parseNumericTerm(term: string): string | null {
|
||||
const trimmed = cleanTerm(term);
|
||||
if (!/^[+-]?(?:\d+|\d+\.\d+|\.\d+)$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function sqlStringLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function likePattern(term: string): string {
|
||||
return `%${cleanTerm(term).toLowerCase().replace(/[~%_]/g, (match) => `~${match}`)}%`;
|
||||
}
|
||||
|
||||
function textCastExpression(databaseType: DatabaseType | undefined, identifier: string): string {
|
||||
if (databaseType === "mysql") return `LOWER(CAST(${identifier} AS CHAR))`;
|
||||
if (databaseType === "sqlserver") return `LOWER(CAST(${identifier} AS NVARCHAR(MAX)))`;
|
||||
if (databaseType === "oracle") return `LOWER(CAST(${identifier} AS VARCHAR2(4000)))`;
|
||||
if (databaseType === "clickhouse") return `lower(toString(${identifier}))`;
|
||||
return `LOWER(CAST(${identifier} AS TEXT))`;
|
||||
}
|
||||
|
||||
function sqlValueLiteral(databaseType: DatabaseType | undefined, column: ColumnInfo | undefined, value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
const stringValue = String(value);
|
||||
if (column && isNumericSearchColumn(column) && parseNumericTerm(stringValue)) {
|
||||
return stringValue.trim();
|
||||
}
|
||||
if (databaseType === "sqlserver") return `N${sqlStringLiteral(stringValue)}`;
|
||||
return sqlStringLiteral(stringValue);
|
||||
}
|
||||
|
||||
export function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions): DatabaseSearchSql | null {
|
||||
const term = cleanTerm(options.term);
|
||||
if (!term) return null;
|
||||
|
||||
const textColumns = options.columns.filter(isTextSearchColumn);
|
||||
const numericTerm = parseNumericTerm(term);
|
||||
const numericColumns = numericTerm ? options.columns.filter(isNumericSearchColumn) : [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
for (const column of textColumns) {
|
||||
const identifier = quoteTableIdentifier(options.databaseType, column.name);
|
||||
conditions.push(`${textCastExpression(options.databaseType, identifier)} LIKE ${sqlStringLiteral(likePattern(term))} ESCAPE '~'`);
|
||||
}
|
||||
|
||||
for (const column of numericColumns) {
|
||||
const identifier = quoteTableIdentifier(options.databaseType, column.name);
|
||||
conditions.push(`${identifier} = ${numericTerm}`);
|
||||
}
|
||||
|
||||
if (!conditions.length) return null;
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.schema,
|
||||
tableName: options.tableName,
|
||||
});
|
||||
const where = conditions.join(" OR ");
|
||||
const limit = clampLimit(options.limit);
|
||||
const searchableColumns = [...textColumns, ...numericColumns].map((column) => column.name);
|
||||
|
||||
if (options.databaseType === "sqlserver") {
|
||||
return {
|
||||
sql: `SELECT TOP ${limit} * FROM ${table} WHERE (${where})`,
|
||||
searchableColumns,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.databaseType === "oracle") {
|
||||
return {
|
||||
sql: `SELECT * FROM ${table} WHERE (${where}) FETCH FIRST ${limit} ROWS ONLY`,
|
||||
searchableColumns,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sql: `SELECT * FROM ${table} WHERE (${where}) LIMIT ${limit};`,
|
||||
searchableColumns,
|
||||
};
|
||||
}
|
||||
|
||||
function columnByName(columns: ColumnInfo[], name: string): ColumnInfo | undefined {
|
||||
return columns.find((column) => column.name.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
export function findMatchedSearchColumns(
|
||||
resultColumns: string[],
|
||||
row: unknown[],
|
||||
columns: ColumnInfo[],
|
||||
term: string,
|
||||
): string[] {
|
||||
const query = cleanTerm(term).toLowerCase();
|
||||
const numericTerm = parseNumericTerm(term);
|
||||
if (!query) return [];
|
||||
|
||||
const matches: string[] = [];
|
||||
resultColumns.forEach((columnName, index) => {
|
||||
const value = row[index];
|
||||
if (value === null || value === undefined) return;
|
||||
const column = columnByName(columns, columnName);
|
||||
if (!column || (!isTextSearchColumn(column) && !isNumericSearchColumn(column))) return;
|
||||
|
||||
if (isNumericSearchColumn(column) && numericTerm && String(value).trim() === numericTerm) {
|
||||
matches.push(columnName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextSearchColumn(column) && String(value).toLowerCase().includes(query)) {
|
||||
matches.push(columnName);
|
||||
}
|
||||
});
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function buildSearchResultWhere(options: SearchResultWhereOptions): string {
|
||||
const valueByColumn = new Map<string, unknown>();
|
||||
options.resultColumns.forEach((column, index) => {
|
||||
valueByColumn.set(column.toLowerCase(), options.row[index]);
|
||||
});
|
||||
|
||||
const primaryColumns = options.columns.filter((column) =>
|
||||
column.is_primary_key && valueByColumn.get(column.name.toLowerCase()) !== undefined,
|
||||
);
|
||||
const fallbackColumns = options.columns.filter((column) =>
|
||||
(options.matchedColumns ?? []).some((name) => name.toLowerCase() === column.name.toLowerCase()) &&
|
||||
valueByColumn.get(column.name.toLowerCase()) !== undefined,
|
||||
);
|
||||
const selectedColumns = (primaryColumns.length ? primaryColumns : fallbackColumns).slice(0, 3);
|
||||
|
||||
return selectedColumns
|
||||
.map((column) => {
|
||||
const identifier = quoteTableIdentifier(options.databaseType, column.name);
|
||||
const value = valueByColumn.get(column.name.toLowerCase());
|
||||
if (value === null || value === undefined) return `${identifier} IS NULL`;
|
||||
return `${identifier} = ${sqlValueLiteral(options.databaseType, column, value)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const tableImportSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
const structureEditorSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
const fieldLineageSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string; columnName: string } | null>(null);
|
||||
const databaseSearchSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
|
||||
|
||||
function startEditing(id: string) {
|
||||
editingConnectionId.value = id;
|
||||
@@ -691,5 +692,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
tableImportSource,
|
||||
structureEditorSource,
|
||||
fieldLineageSource,
|
||||
databaseSearchSource,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ColumnInfo } from "../src/types/database.ts";
|
||||
import {
|
||||
buildDatabaseSearchSql,
|
||||
buildSearchResultWhere,
|
||||
findMatchedSearchColumns,
|
||||
} from "../src/lib/databaseSearch.ts";
|
||||
|
||||
function col(name: string, dataType: string, primary = false): ColumnInfo {
|
||||
return {
|
||||
name,
|
||||
data_type: dataType,
|
||||
is_nullable: true,
|
||||
column_default: null,
|
||||
is_primary_key: primary,
|
||||
extra: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("builds a table search query over text columns", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "mysql",
|
||||
tableName: "users",
|
||||
columns: [col("id", "bigint", true), col("email", "varchar"), col("avatar", "blob")],
|
||||
term: "alice@example.com",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
assert.ok(query);
|
||||
assert.deepEqual(query.searchableColumns, ["email"]);
|
||||
assert.equal(
|
||||
query.sql,
|
||||
"SELECT * FROM `users` WHERE (LOWER(CAST(`email` AS CHAR)) LIKE '%alice@example.com%' ESCAPE '~') LIMIT 20;",
|
||||
);
|
||||
});
|
||||
|
||||
test("adds exact numeric predicates only when the search term is numeric", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "postgres",
|
||||
schema: "public",
|
||||
tableName: "orders",
|
||||
columns: [col("id", "integer", true), col("note", "text")],
|
||||
term: "42",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
assert.ok(query);
|
||||
assert.deepEqual(query.searchableColumns, ["note", "id"]);
|
||||
assert.match(query.sql, /"id" = 42/);
|
||||
assert.match(query.sql, /FROM "public"\."orders"/);
|
||||
});
|
||||
|
||||
test("returns null when a table has no searchable columns", () => {
|
||||
const query = buildDatabaseSearchSql({
|
||||
databaseType: "sqlite",
|
||||
tableName: "files",
|
||||
columns: [col("payload", "blob")],
|
||||
term: "needle",
|
||||
});
|
||||
|
||||
assert.equal(query, null);
|
||||
});
|
||||
|
||||
test("finds matched columns from returned rows", () => {
|
||||
const matches = findMatchedSearchColumns(
|
||||
["id", "email", "note"],
|
||||
[42, "Alice@Example.com", "inactive"],
|
||||
[col("id", "integer", true), col("email", "varchar"), col("note", "text")],
|
||||
"alice",
|
||||
);
|
||||
|
||||
assert.deepEqual(matches, ["email"]);
|
||||
});
|
||||
|
||||
test("builds a stable where predicate for opening a result row", () => {
|
||||
const where = buildSearchResultWhere({
|
||||
databaseType: "sqlserver",
|
||||
columns: [col("id", "integer", true), col("email", "varchar")],
|
||||
resultColumns: ["id", "email"],
|
||||
row: [42, "alice@example.com"],
|
||||
});
|
||||
|
||||
assert.equal(where, "[id] = 42");
|
||||
});
|
||||
Reference in New Issue
Block a user