mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
🐛 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:
@@ -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)
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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),
|
||||
|
||||
30
frontend/src/utils/tableMetadataRows.test.ts
Normal file
30
frontend/src/utils/tableMetadataRows.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
48
frontend/src/utils/tableMetadataRows.ts
Normal file
48
frontend/src/utils/tableMetadataRows.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user