From 5534b13fde2ec0fa6951112af24c0070a43d7a85 Mon Sep 17 00:00:00 2001 From: SuLea-IT <105108570+SuLea-IT@users.noreply.github.com> Date: Mon, 4 May 2026 12:01:19 +0800 Subject: [PATCH] feat: add database-wide search --- src/App.vue | 66 +++ .../search/DatabaseSearchDialog.vue | 377 ++++++++++++++++++ src/components/sidebar/TreeItem.vue | 20 +- src/i18n/locales/en.ts | 20 + src/i18n/locales/zh-CN.ts | 20 + src/lib/databaseSearch.ts | 217 ++++++++++ src/stores/connectionStore.ts | 2 + tests/databaseSearch.test.ts | 85 ++++ 8 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 src/components/search/DatabaseSearchDialog.vue create mode 100644 src/lib/databaseSearch.ts create mode 100644 tests/databaseSearch.test.ts diff --git a/src/App.vue b/src/App.vue index 31d1d99052..29b8b27b19 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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"; @@ -132,6 +133,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(""); @@ -159,6 +161,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; @@ -166,6 +171,13 @@ type LineageNavigationTarget = { tableName: string; columnName?: string; }; +type DatabaseSearchNavigationTarget = { + connectionId: string; + database: string; + schema?: string; + tableName: string; + whereInput?: string; +}; const databaseOptions = ref>({}); const loadingDatabaseOptions = ref>({}); const checkingUpdates = ref(false); @@ -265,6 +277,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) { @@ -323,6 +345,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 = ""; @@ -1636,6 +1695,13 @@ async function setupFileDrop() { :prefill-column="lineagePrefillColumn" @open-target="openLineageTarget" /> + +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(["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([]); +const tableErrors = ref>([]); +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 { + 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, + }); +} + + + diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 1440ac8a1a..f5a4041667 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -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, @@ -43,6 +43,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"]); @@ -516,6 +517,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; @@ -561,6 +572,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); @@ -708,6 +723,9 @@ async function showMore() { {{ t('diagram.open') }} + + {{ t('databaseSearch.open') }} + {{ t('contextMenu.refreshChildren') }} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c7e2a368ec..197e20f276 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f855620831..3bc2966a9f 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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: "解密导入", diff --git a/src/lib/databaseSearch.ts b/src/lib/databaseSearch.ts new file mode 100644 index 0000000000..4d50e4defa --- /dev/null +++ b/src/lib/databaseSearch.ts @@ -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(); + 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 "); +} diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 74c570b09c..9a38d3ada5 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -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, }; }); diff --git a/tests/databaseSearch.test.ts b/tests/databaseSearch.test.ts new file mode 100644 index 0000000000..8c334ccac3 --- /dev/null +++ b/tests/databaseSearch.test.ts @@ -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"); +});