diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index eb612fa8..2c62da4f 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -48,6 +48,7 @@ import { } from "../utils/connectionDriverType"; import { resolveSqlDialect } from "../utils/sqlDialect"; import { quoteIdentPart, quoteQualifiedIdent } from "../utils/sql"; +import { normalizeTableNamesFromMetadataRows } from "../utils/tableMetadataRows"; import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText, @@ -722,19 +723,7 @@ const DataSyncModal: React.FC<{ const config = normalizeConnConfig(conn, dbName); const res = await DBGetTables(config as any, dbName); if (res.success) { - // DBGetTables returns [{Table: "name"}, ...] - const tableRows = Array.isArray(res.data) ? res.data : []; - const tables = tableRows - .map( - (row: any) => - row?.Table || - row?.table || - row?.TABLE_NAME || - Object.values(row || {})[0], - ) - .filter( - (name: any) => typeof name === "string" && name.trim() !== "", - ); + const tables = normalizeTableNamesFromMetadataRows(res.data); const nextTables = ( isSourceQueryMode && targetSupportsSchemaSelection && targetSchema ? filterTablesBySchema(tables as string[], targetSchema) diff --git a/frontend/src/components/FindInDatabaseModal.tsx b/frontend/src/components/FindInDatabaseModal.tsx index f8fe330d..e8fd6d35 100644 --- a/frontend/src/components/FindInDatabaseModal.tsx +++ b/frontend/src/components/FindInDatabaseModal.tsx @@ -9,6 +9,7 @@ import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isMacLikePlatform } from '../utils/appearance'; import { useI18n } from '../i18n/provider'; +import { normalizeTableNamesFromMetadataRows } from '../utils/tableMetadataRows'; interface FindInDatabaseModalProps { open: boolean; @@ -117,8 +118,7 @@ const FindInDatabaseModal: React.FC = ({ open, onClose setSearching(false); return; } - const tableRows: any[] = Array.isArray(tablesRes.data) ? tablesRes.data : []; - const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean); + const tableNames = normalizeTableNamesFromMetadataRows(tablesRes.data); if (tableNames.length === 0) { message.info(t('find_in_database.message.no_tables')); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 4ae07b3b..459bf454 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -29,6 +29,7 @@ import { } from '../utils/queryResultPagination'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; +import { extractTableNameFromMetadataRow } from '../utils/tableMetadataRows'; import { formatSqlExecutionError, hasLocalizedSqlTimeoutKeyword } from '../utils/sqlErrorSemantics'; import { canReusePendingSqlEditorTransactionForType, shouldUseSqlEditorManagedTransactionForType } from '../utils/sqlEditorTransaction'; import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; @@ -1009,10 +1010,7 @@ const clearRecord = (record: Record) => { const QUERY_EDITOR_SQL_SNIPPET_SUGGEST_DETAIL_MIN_HEIGHT = 260; const getCompletionTableNameFromRow = (row: any): string => ( - normalizeCommentText( - getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'Table', 'table', 'name', 'Name']) - ?? Object.values(row || {})[0], - ) + normalizeCommentText(extractTableNameFromMetadataRow(row)) ); const getCompletionTableCommentFromRow = (row: any): string => ( @@ -6973,7 +6971,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } const fetchedTables = resTables.data .map((row: any) => { - const tableName = String(Object.values(row || {})[0] || '').trim(); + const tableName = extractTableNameFromMetadataRow(row); if (!tableName) return null; return { dbName: normalizedDbName, diff --git a/frontend/src/components/TableExportWorkbench.tsx b/frontend/src/components/TableExportWorkbench.tsx index 00455019..762220e7 100644 --- a/frontend/src/components/TableExportWorkbench.tsx +++ b/frontend/src/components/TableExportWorkbench.tsx @@ -22,6 +22,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { getColumnDefinitionName } from '../utils/columnDefinition'; import { resolveConnectionHostSummary } from '../utils/tabDisplay'; import { buildExportWorkbenchHistoryKey } from '../utils/tableExportTab'; +import { normalizeTableNamesFromMetadataRows } from '../utils/tableMetadataRows'; import { formatExportElapsed, formatExportProgressRows, @@ -521,11 +522,8 @@ const TableExportWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { setObjectLoadError(res.message || t('data_export.message.load_objects_failed')); return; } - const tableRows: any[] = Array.isArray(res.data) ? res.data : []; const nextOptions = toSortedSelectOptions( - tableRows - .map((row) => String(Object.values(row)[0] || '').trim()) - .filter(Boolean), + normalizeTableNamesFromMetadataRows(res.data), ); setAvailableObjects(nextOptions); const availableNameSet = new Set(nextOptions.map((item) => item.value)); diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 3d364b42..174b362e 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -27,6 +27,7 @@ import { getShortcutPlatform } from '../utils/shortcuts'; import { t } from '../i18n'; import { buildTableExportTab } from '../utils/tableExportTab'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; +import { extractTableNameFromMetadataRow } from '../utils/tableMetadataRows'; import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu'; import { useExportProgressDialog } from './ExportProgressModal'; import { showSQLExportOptionsDialog } from './SQLExportOptionsDialog'; @@ -237,7 +238,7 @@ const parseTableStats = (dialect: string, rows: Record[]): TableSta }; return { - name: strVal(['Name', 'name', 'table_name', 'tablename', 'TABLE_NAME', 'Table', 'table', 'Device', 'device']), + name: extractTableNameFromMetadataRow(row) || strVal(['Device', 'device']), comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']), rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows'], -1), dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes'], -1), diff --git a/frontend/src/components/ai/aiDatabaseToolHelpers.ts b/frontend/src/components/ai/aiDatabaseToolHelpers.ts index 89b23242..38f77db1 100644 --- a/frontend/src/components/ai/aiDatabaseToolHelpers.ts +++ b/frontend/src/components/ai/aiDatabaseToolHelpers.ts @@ -1,8 +1,9 @@ import type { SavedConnection } from '../../types'; import { buildPaginatedSelectSQL, quoteQualifiedIdent } from '../../utils/sql'; +import { normalizeTableNamesFromMetadataRows } from '../../utils/tableMetadataRows'; export const normalizeTableList = (rows: any[]): string[] => - rows.map((row) => row.Table || row.table || (Object.values(row)[0] as string)); + normalizeTableNamesFromMetadataRows(rows); export const normalizeColumns = (rows: any[]) => rows.map((column) => { diff --git a/frontend/src/components/ai/useAIChatContextBinding.test.tsx b/frontend/src/components/ai/useAIChatContextBinding.test.tsx index 64202fd7..6cbcf1f2 100644 --- a/frontend/src/components/ai/useAIChatContextBinding.test.tsx +++ b/frontend/src/components/ai/useAIChatContextBinding.test.tsx @@ -172,6 +172,40 @@ describe('useAIChatContextBinding', () => { }); }); + it('uses the named table field instead of metadata values such as row counts', async () => { + dbGetDatabasesMock.mockResolvedValue({ + success: true, + data: [{ Database: 'analytics' }], + }); + dbGetTablesMock.mockResolvedValue({ + success: true, + data: [ + { Rows: '128', Table: 'users', Data_length: '4096' }, + { Index_length: '2048', table_name: 'orders', Rows: '42' }, + { Name: 'metadata-label', Rows: '7', TABLE: 'customers' }, + ], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await latestHook!.handleOpenContext(); + }); + + expect(latestHook!.filteredTables).toEqual([ + { name: 'users' }, + { name: 'orders' }, + { name: 'customers' }, + ]); + + await act(async () => { + renderer!.unmount(); + }); + }); + it('falls back to the English unchanged-selection info message after a no-op sync', async () => { let renderer: ReactTestRenderer; await act(async () => { diff --git a/frontend/src/components/ai/useAIChatContextBinding.ts b/frontend/src/components/ai/useAIChatContextBinding.ts index 63320f02..f1f62b43 100644 --- a/frontend/src/components/ai/useAIChatContextBinding.ts +++ b/frontend/src/components/ai/useAIChatContextBinding.ts @@ -7,6 +7,7 @@ import type { AIContextItem } from '../../types'; import { useStore } from '../../store'; import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool'; +import { normalizeTableNamesFromMetadataRows } from '../../utils/tableMetadataRows'; import { DBGetColumns, DBGetDatabases, DBGetTables, DBShowCreateTable } from '../../../wailsjs/go/app/App'; interface ActiveContextRef { @@ -36,6 +37,10 @@ const getErrorDetail = (value: unknown): string => { return detail || 'unknown error'; }; +export const normalizeAIContextTables = (data: unknown): { name: string }[] => { + return normalizeTableNamesFromMetadataRows(data).map((name) => ({ name })); +}; + export const useAIChatContextBinding = ({ activeContext, activeContextItems, @@ -73,7 +78,7 @@ export const useAIChatContextBinding = ({ try { const res = await DBGetTables(buildRpcConnectionConfig(connConfig), dbName); if (res.success && Array.isArray(res.data)) { - setContextTables(res.data.map((row) => ({ name: Object.values(row)[0] as string }))); + setContextTables(normalizeAIContextTables(res.data)); } else { const detail = getErrorDetail(res.message); message.error(translateMessage( @@ -125,7 +130,7 @@ export const useAIChatContextBinding = ({ setSelectedDbName(initialDbName); const tablesRes = await DBGetTables(buildRpcConnectionConfig(connection.config) as any, initialDbName); if (tablesRes.success && Array.isArray(tablesRes.data)) { - setContextTables(tablesRes.data.map((row: any) => ({ name: Object.values(row)[0] as string }))); + setContextTables(normalizeAIContextTables(tablesRes.data)); } else { const detail = getErrorDetail(tablesRes.message); message.error(translateMessage( diff --git a/frontend/src/components/dataGridErDiagramModel.ts b/frontend/src/components/dataGridErDiagramModel.ts index 59d9454b..caf0d77e 100644 --- a/frontend/src/components/dataGridErDiagramModel.ts +++ b/frontend/src/components/dataGridErDiagramModel.ts @@ -1,4 +1,5 @@ import type { ColumnDefinition, ForeignKeyDefinition } from '../types'; +import { extractTableNameFromMetadataRow } from '../utils/tableMetadataRows'; export type ErDiagramRelationDirection = 'incoming' | 'outgoing' | 'self'; @@ -160,16 +161,7 @@ export const extractErTableNames = (rows: unknown): string[] => { const seen = new Set(); const result: string[] = []; rows.forEach((row) => { - const candidate = readText(row, [ - 'table', - 'Table', - 'TABLE', - 'tableName', - 'TableName', - 'TABLE_NAME', - 'name', - 'Name', - ]) || String(Object.values((row as Record) || {})[0] || '').trim(); + const candidate = extractTableNameFromMetadataRow(row); const normalized = normalizeErQualifiedName(candidate); if (!normalized || seen.has(normalized)) { return; diff --git a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts index 5bc81992..56d67cc4 100644 --- a/frontend/src/components/sidebar/sidebarMetadataLoaders.ts +++ b/frontend/src/components/sidebar/sidebarMetadataLoaders.ts @@ -12,6 +12,7 @@ import { type SidebarViewMetadataEntry, } from "../../utils/sidebarMetadata"; import { isPostgresSchemaDialect } from "../sidebarCoreUtils"; +import { extractTableNameFromMetadataRow } from "../../utils/tableMetadataRows"; export const buildSidebarRuntimeConfig = ( conn: any, @@ -232,14 +233,7 @@ const getMySQLShowTablesName = (row: Record): string => { }; const getSidebarTableName = (row: Record): string => { - return getCaseInsensitiveValue(row, [ - "Table", - "table", - "table_name", - "TABLE_NAME", - "Name", - "name", - ]) || getMySQLShowTablesName(row) || getFirstRowValue(row); + return extractTableNameFromMetadataRow(row); }; const parseMetadataRowCount = ( diff --git a/frontend/src/components/sidebar/useSidebarBatchExport.ts b/frontend/src/components/sidebar/useSidebarBatchExport.ts index 170cfffc..0fc90a0d 100644 --- a/frontend/src/components/sidebar/useSidebarBatchExport.ts +++ b/frontend/src/components/sidebar/useSidebarBatchExport.ts @@ -16,6 +16,7 @@ import type { SavedConnection } from '../../types'; import { t } from '../../i18n'; import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; import type { SidebarViewMetadataEntry } from '../../utils/sidebarMetadata'; +import { normalizeTableNamesFromMetadataRows } from '../../utils/tableMetadataRows'; import { buildBatchDatabaseExportWorkbenchTab, buildBatchTableExportWorkbenchTab, @@ -341,7 +342,7 @@ export const useSidebarBatchExport = ({ return; } - const tableRows: any[] = Array.isArray(res.data) ? res.data : []; + const tableNames = normalizeTableNamesFromMetadataRows(res.data); const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewResult.views) ? viewResult.views : []; const viewSet = new Set( viewRows.flatMap((view) => { @@ -353,8 +354,7 @@ export const useSidebarBatchExport = ({ }) ); - const tableObjects: BatchObjectItem[] = tableRows - .map((row: any) => Object.values(row)[0] as string) + const tableObjects: BatchObjectItem[] = tableNames .filter((tableName: string) => !viewSet.has(tableName.toLowerCase())) .map((tableName: string) => ({ title: getSidebarTableDisplayName(conn, tableName), diff --git a/frontend/src/utils/tableMetadataRows.test.ts b/frontend/src/utils/tableMetadataRows.test.ts new file mode 100644 index 00000000..d6c320d8 --- /dev/null +++ b/frontend/src/utils/tableMetadataRows.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractTableNameFromMetadataRow, + normalizeTableNamesFromMetadataRows, +} from './tableMetadataRows'; + +describe('table metadata rows', () => { + it('prefers explicit table fields over row and storage statistics', () => { + expect(extractTableNameFromMetadataRow({ Rows: '128', Table: 'users', Data_length: '4096' })).toBe('users'); + expect(extractTableNameFromMetadataRow({ Name: 'metadata-label', TABLE: 'customers' })).toBe('customers'); + expect(extractTableNameFromMetadataRow({ Index_length: '2048', table_name: 'orders' })).toBe('orders'); + }); + + it('supports legacy one-column and MySQL table-list rows without guessing multi-field metadata', () => { + expect(extractTableNameFromMetadataRow({ Tables_in_app: 'events' })).toBe('events'); + expect(extractTableNameFromMetadataRow({ arbitrary_column: 'legacy_table' })).toBe('legacy_table'); + expect(extractTableNameFromMetadataRow({ Rows: '12', Data_length: '2048' })).toBe(''); + }); + + it('normalizes string and object rows while removing empty and duplicate names', () => { + expect(normalizeTableNamesFromMetadataRows([ + ' users ', + { Table: 'orders', Rows: '42' }, + { table_name: 'users' }, + { Table: '' }, + null, + ])).toEqual(['users', 'orders']); + }); +}); diff --git a/frontend/src/utils/tableMetadataRows.ts b/frontend/src/utils/tableMetadataRows.ts new file mode 100644 index 00000000..1bbf8554 --- /dev/null +++ b/frontend/src/utils/tableMetadataRows.ts @@ -0,0 +1,48 @@ +const TABLE_NAME_KEY_PRIORITY = ['table', 'table_name', 'tablename', 'name'] as const; + +const toNonEmptyText = (value: unknown): string => String(value ?? '').trim(); + +export const extractTableNameFromMetadataRow = (row: unknown): string => { + if (typeof row === 'string') { + return row.trim(); + } + if (!row || typeof row !== 'object' || Array.isArray(row)) { + return ''; + } + + const entries = Object.entries(row as Record); + const valuesByKey = new Map(entries.map(([key, value]) => [key.trim().toLowerCase(), value])); + + for (const key of TABLE_NAME_KEY_PRIORITY) { + const name = toNonEmptyText(valuesByKey.get(key)); + if (name) { + return name; + } + } + + const mysqlTableEntry = entries.find(([key]) => key.trim().toLowerCase().startsWith('tables_in_')); + const mysqlTableName = toNonEmptyText(mysqlTableEntry?.[1]); + if (mysqlTableName) { + return mysqlTableName; + } + + return entries.length === 1 ? toNonEmptyText(entries[0][1]) : ''; +}; + +export const normalizeTableNamesFromMetadataRows = (rows: unknown): string[] => { + if (!Array.isArray(rows)) { + return []; + } + + const seen = new Set(); + const names: string[] = []; + rows.forEach((row) => { + const name = extractTableNameFromMetadataRow(row); + if (!name || seen.has(name)) { + return; + } + seen.add(name); + names.push(name); + }); + return names; +};