🐛 fix(table-metadata): 修复统计字段被误识别为表名 (#666)

## 背景

DBGetTables 会同时返回 Table、Rows、Data_length 和 Index_length 等字段。部分前端入口通过
Object.values(row)[0] 获取表名,Go map 序列化后的字段顺序变化时,行数或存储大小会被误当成表名。

## 变更内容

- 新增共享表元数据解析器,优先读取明确的表名字段
- 兼容 Table、table_name、tableName、name、MySQL Tables_in_*、字符串行和旧单字段响应
- 多字段响应缺少明确表名时不再猜测首个字段
- 统一 AI 关联上下文、AI 数据库工具、查询补全、ER 图、侧边栏、全库搜索、数据同步、批量导出、导出工作台和表概览的解析逻辑
- 补充统计字段顺序、兼容格式、空值和重复名称的回归测试

## 影响范围

仅调整前端对 DBGetTables 返回值的表名解析,不修改后端 API、数据库查询或表统计逻辑。

## 验证

- 12 个相关 Vitest 文件,共 62 条测试通过
- npm run build 通过,8669 个模块完成生产构建
- git diff --check 通过
This commit is contained in:
Syngnat
2026-07-18 21:49:58 +08:00
committed by GitHub
13 changed files with 139 additions and 49 deletions

View File

@@ -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)

View File

@@ -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<FindInDatabaseModalProps> = ({ 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'));

View File

@@ -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<string, unknown>) => {
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,

View File

@@ -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));

View File

@@ -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<string, any>[]): 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),

View File

@@ -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) => {

View File

@@ -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(<HookHarness />);
});
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 () => {

View File

@@ -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(

View File

@@ -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<string>();
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<string, unknown>) || {})[0] || '').trim();
const candidate = extractTableNameFromMetadataRow(row);
const normalized = normalizeErQualifiedName(candidate);
if (!normalized || seen.has(normalized)) {
return;

View File

@@ -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, any>): string => {
};
const getSidebarTableName = (row: Record<string, any>): string => {
return getCaseInsensitiveValue(row, [
"Table",
"table",
"table_name",
"TABLE_NAME",
"Name",
"name",
]) || getMySQLShowTablesName(row) || getFirstRowValue(row);
return extractTableNameFromMetadataRow(row);
};
const parseMetadataRowCount = (

View File

@@ -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),

View File

@@ -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']);
});
});

View File

@@ -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<string, unknown>);
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<string>();
const names: string[] = [];
rows.forEach((row) => {
const name = extractTableNameFromMetadataRow(row);
if (!name || seen.has(name)) {
return;
}
seen.add(name);
names.push(name);
});
return names;
};