From 5ffaa4361eccd86e1431dc984e5e8132ab8896d0 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 1 Jun 2026 09:32:18 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(metadata):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Oracle=20=E5=AD=97=E6=AE=B5=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=98=BE=E7=A4=BA=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Oracle 元数据查询为字段名、类型、默认值、注释等列补齐稳定别名 - 新增字段定义归一化工具,兼容 name/Name/COLUMN_NAME 等返回形态 - 修复 DataGrid、DataViewer、QueryEditor、TableDesigner 对字段元数据的读取 - 补充 Oracle 字段注释、表头元数据和主键定位回归测试 --- frontend/src/components/DataGrid.ddl.test.tsx | 5 +- frontend/src/components/DataGrid.tsx | 11 +++- .../DataViewer.primary-key.test.tsx | 2 +- frontend/src/components/DataViewer.tsx | 15 +++-- frontend/src/components/QueryEditor.tsx | 10 +++- frontend/src/components/TableDesigner.tsx | 8 ++- frontend/src/utils/columnDefinition.test.ts | 31 ++++++++++ frontend/src/utils/columnDefinition.ts | 59 +++++++++++++++++++ internal/db/oracle_get_tables_test.go | 5 ++ internal/db/oracle_impl.go | 12 ++-- 10 files changed, 137 insertions(+), 21 deletions(-) create mode 100644 frontend/src/utils/columnDefinition.test.ts create mode 100644 frontend/src/utils/columnDefinition.ts diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index bef8473..1c6c5cf 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -139,6 +139,7 @@ vi.mock('@ant-design/icons', () => { RobotOutlined: Icon, SearchOutlined: Icon, LinkOutlined: Icon, + AimOutlined: Icon, TableOutlined: Icon, AimOutlined: Icon, SortAscendingOutlined: Icon, @@ -664,7 +665,7 @@ describe('DataGrid DDL interactions', () => { storeState.queryOptions.showColumnType = true; backendApp.DBGetColumns.mockResolvedValueOnce({ success: true, - data: [{ name: 'id', type: 'bigint', comment: '主键 ID' }], + data: [{ Name: 'id', Type: 'bigint', Comment: '主键 ID' }], }); let renderer: ReactTestRenderer; @@ -703,6 +704,8 @@ describe('DataGrid DDL interactions', () => { expect(textContent(renderer!.root)).toContain('隐藏此字段'); expect(textContent(renderer!.root)).toContain('隐藏字段类型'); expect(textContent(renderer!.root)).toContain('隐藏字段备注'); + expect(textContent(renderer!.root)).toContain('bigint'); + expect(textContent(renderer!.root)).toContain('主键 ID'); renderer!.unmount(); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 27a8d34..1f88ede 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -104,6 +104,11 @@ import { resolveRowLocatorValues, type EditRowLocator, } from '../utils/rowLocator'; +import { + getColumnDefinitionComment, + getColumnDefinitionName, + getColumnDefinitionType, +} from '../utils/columnDefinition'; import { V2CellContextMenuView, V2ColumnHeaderContextMenuView, @@ -2142,10 +2147,10 @@ const DataGrid: React.FC = ({ } const nextMap: Record = {}; (res.data as ColumnDefinition[]).forEach((column: any) => { - const name = String(column?.name ?? column?.Name ?? '').trim(); + const name = getColumnDefinitionName(column); if (!name) return; - const type = String(column?.type ?? column?.Type ?? '').trim(); - const comment = String(column?.comment ?? column?.Comment ?? '').trim(); + const type = getColumnDefinitionType(column); + const comment = getColumnDefinitionComment(column); nextMap[name] = { type, comment }; }); columnMetaCacheRef.current[cacheKey] = nextMap; diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index 8df91dd..be0b3fd 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -120,7 +120,7 @@ describe('DataViewer safe editing locator', () => { it('enables table preview editing after primary keys are loaded', async () => { backendApp.DBGetColumns.mockResolvedValue({ success: true, - data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }], + data: [{ Name: 'ID', Key: 'PRI' }, { Name: 'NAME', Key: '' }], }); const renderer = await renderAndReload(); diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 8ec1409..bcae350 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -21,6 +21,11 @@ import { type EditRowLocator, } from '../utils/rowLocator'; import { isOracleLikeDialect } from '../utils/sqlDialect'; +import { + getColumnDefinitionKey, + getColumnDefinitionName, + getColumnDefinitionType, +} from '../utils/columnDefinition'; type ViewerPaginationState = { current: number; @@ -104,7 +109,7 @@ const formatDataViewerTableName = (dbName: string, tableName: string): string => const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[] => ( (columns || []) - .map((column) => String(column?.name || '').trim()) + .map(getColumnDefinitionName) .filter(Boolean) ); @@ -572,8 +577,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ } else { const columnDefs = resCols.data as ColumnDefinition[]; const primaryKeys = columnDefs - .filter((column: any) => column?.key === 'PRI') - .map((column: any) => String(column?.name || '').trim()) + .filter((column: any) => getColumnDefinitionKey(column) === 'PRI') + .map(getColumnDefinitionName) .filter(Boolean); const indexes = resIndexes?.success && Array.isArray(resIndexes.data) ? resIndexes.data as IndexDefinition[] @@ -721,10 +726,10 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ if (resCols?.success && Array.isArray(resCols.data)) { const columnDefs = resCols.data as ColumnDefinition[]; const selectParts = columnDefs.map((col) => { - const colName = String(col?.name || '').trim(); + const colName = getColumnDefinitionName(col); if (!colName) return ''; const quotedCol = quoteIdentPart(dbType, colName); - if (isDuckDBComplexColumnType(col?.type)) { + if (isDuckDBComplexColumnType(getColumnDefinitionType(col))) { return `CAST(${quotedCol} AS VARCHAR) AS ${quotedCol}`; } return quotedCol; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 20e359c..920fef3 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -23,6 +23,10 @@ import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; import { normalizeSidebarViewName } from '../utils/sidebarMetadata'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator'; +import { + getColumnDefinitionKey, + getColumnDefinitionName, +} from '../utils/columnDefinition'; const SQL_KEYWORDS = [ 'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', @@ -1530,10 +1534,10 @@ const resolveQueryLocatorPlan = async ({ } const tableColumns = resCols.data as ColumnDefinition[]; - const tableColumnNames = tableColumns.map((column) => String(column?.name || '').trim()).filter(Boolean); + const tableColumnNames = tableColumns.map(getColumnDefinitionName).filter(Boolean); const primaryKeys = tableColumns - .filter((column: any) => column?.key === 'PRI') - .map((column: any) => String(column?.name || '').trim()) + .filter((column: any) => getColumnDefinitionKey(column) === 'PRI') + .map(getColumnDefinitionName) .filter(Boolean); const indexes = resIndexes?.success && Array.isArray(resIndexes.data) ? resIndexes.data as IndexDefinition[] diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 941f99b..74b7a6a 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -15,6 +15,10 @@ import { normalizeSchemaStatementForExecution, parseTableCommentFromDDL, splitSc import TableDesignerSqlPreview from './TableDesignerSqlPreview'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; +import { + getColumnDefinitionExtra, + normalizeColumnDefinition, +} from '../utils/columnDefinition'; import { isMysqlFamilyDialect as isMysqlFamilySqlDialect, isOracleLikeDialect as isOracleLikeSqlDialect, @@ -804,9 +808,9 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { if (colsRes.success) { const colsWithKey = (colsRes.data as ColumnDefinition[]).map((c, index) => ({ - ...c, + ...normalizeColumnDefinition(c), _key: `col-${index}-${Date.now()}`, - isAutoIncrement: c.extra && c.extra.toLowerCase().includes('auto_increment') + isAutoIncrement: getColumnDefinitionExtra(c).toLowerCase().includes('auto_increment') })); setColumns(JSON.parse(JSON.stringify(colsWithKey))); setOriginalColumns(JSON.parse(JSON.stringify(colsWithKey))); diff --git a/frontend/src/utils/columnDefinition.test.ts b/frontend/src/utils/columnDefinition.test.ts new file mode 100644 index 0000000..41122f5 --- /dev/null +++ b/frontend/src/utils/columnDefinition.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { + getColumnDefinitionComment, + getColumnDefinitionKey, + getColumnDefinitionName, + getColumnDefinitionType, + normalizeColumnDefinition, +} from './columnDefinition'; + +describe('columnDefinition metadata normalization', () => { + it('reads Go/Wails style column metadata fields', () => { + const column = { Name: 'UPDATED_AT', Type: 'TIMESTAMP', Key: 'PRI', Comment: '更新时间' }; + + expect(getColumnDefinitionName(column)).toBe('UPDATED_AT'); + expect(getColumnDefinitionType(column)).toBe('TIMESTAMP'); + expect(getColumnDefinitionKey(column)).toBe('PRI'); + expect(getColumnDefinitionComment(column)).toBe('更新时间'); + }); + + it('reads Oracle dictionary style column metadata aliases', () => { + const column = { COLUMN_NAME: 'UPDATED_AT', DATA_TYPE: 'TIMESTAMP', COLUMN_KEY: 'PRI', COMMENTS: '更新时间' }; + + expect(normalizeColumnDefinition(column)).toMatchObject({ + name: 'UPDATED_AT', + type: 'TIMESTAMP', + key: 'PRI', + comment: '更新时间', + }); + }); +}); diff --git a/frontend/src/utils/columnDefinition.ts b/frontend/src/utils/columnDefinition.ts new file mode 100644 index 0000000..c9e85cb --- /dev/null +++ b/frontend/src/utils/columnDefinition.ts @@ -0,0 +1,59 @@ +import type { ColumnDefinition } from '../types'; + +const readStringProperty = (value: unknown, keys: string[]): string => { + const source = value as Record | null | undefined; + if (!source || typeof source !== 'object') return ''; + + for (const key of keys) { + const raw = source[key]; + if (raw !== undefined && raw !== null) { + return String(raw).trim(); + } + } + + for (const [sourceKey, raw] of Object.entries(source)) { + if (keys.some((key) => sourceKey.toLowerCase() === key.toLowerCase())) { + return raw === undefined || raw === null ? '' : String(raw).trim(); + } + } + + return ''; +}; + +export const getColumnDefinitionName = (column: unknown): string => ( + readStringProperty(column, ['name', 'Name', 'COLUMN_NAME', 'column_name', 'field', 'Field']) +); + +export const getColumnDefinitionType = (column: unknown): string => ( + readStringProperty(column, ['type', 'Type', 'DATA_TYPE', 'data_type']) +); + +export const getColumnDefinitionKey = (column: unknown): string => ( + readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key']) +); + +export const getColumnDefinitionExtra = (column: unknown): string => ( + readStringProperty(column, ['extra', 'Extra']) +); + +export const getColumnDefinitionComment = (column: unknown): string => ( + readStringProperty(column, ['comment', 'Comment', 'COMMENTS', 'comments', 'COLUMN_COMMENT', 'column_comment']) +); + +export const normalizeColumnDefinition = (column: unknown): ColumnDefinition => { + const source = (column && typeof column === 'object' ? column : {}) as Partial; + return { + ...source, + name: getColumnDefinitionName(column), + type: getColumnDefinitionType(column), + nullable: readStringProperty(column, ['nullable', 'Nullable', 'NULLABLE', 'is_nullable']), + key: getColumnDefinitionKey(column), + default: source.default, + extra: getColumnDefinitionExtra(column), + comment: getColumnDefinitionComment(column), + }; +}; + +export const normalizeColumnDefinitions = (columns: unknown): ColumnDefinition[] => ( + Array.isArray(columns) ? columns.map(normalizeColumnDefinition) : [] +); diff --git a/internal/db/oracle_get_tables_test.go b/internal/db/oracle_get_tables_test.go index 2db3b61..97608a4 100644 --- a/internal/db/oracle_get_tables_test.go +++ b/internal/db/oracle_get_tables_test.go @@ -104,6 +104,11 @@ func TestOracleGetColumnsIncludesColumnComments(t *testing.T) { if len(queries) == 0 || !strings.Contains(queries[0], "all_col_comments") { t.Fatalf("expected GetColumns to join all_col_comments, queries=%v", queries) } + for _, want := range []string{`AS "COLUMN_NAME"`, `AS "DATA_TYPE"`, `AS "COMMENT"`} { + if !strings.Contains(queries[0], want) { + t.Fatalf("expected GetColumns query to contain stable alias %q, got %s", want, queries[0]) + } + } } func TestOracleGetCreateStatementAppendsTableAndColumnComments(t *testing.T) { diff --git a/internal/db/oracle_impl.go b/internal/db/oracle_impl.go index d1ec431..d5ad107 100644 --- a/internal/db/oracle_impl.go +++ b/internal/db/oracle_impl.go @@ -325,9 +325,9 @@ func (o *OracleDB) GetCreateStatement(dbName, tableName string) (string, error) func (o *OracleDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { metadataTableName := escapeOracleMetadataLiteral(tableName) metadataSchemaName := escapeOracleMetadataLiteral(dbName) - query := fmt.Sprintf(`SELECT c.column_name, c.data_type, c.nullable, c.data_default, - CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END AS column_key, - cc.comments AS comment + query := fmt.Sprintf(`SELECT c.column_name AS "COLUMN_NAME", c.data_type AS "DATA_TYPE", c.nullable AS "NULLABLE", c.data_default AS "DATA_DEFAULT", + CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END AS "COLUMN_KEY", + cc.comments AS "COMMENT" FROM all_tab_columns c LEFT JOIN all_col_comments cc ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name @@ -342,9 +342,9 @@ func (o *OracleDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi ORDER BY c.column_id`, metadataSchemaName, metadataTableName) if dbName == "" { - query = fmt.Sprintf(`SELECT c.column_name, c.data_type, c.nullable, c.data_default, - CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END AS column_key, - cc.comments AS comment + query = fmt.Sprintf(`SELECT c.column_name AS "COLUMN_NAME", c.data_type AS "DATA_TYPE", c.nullable AS "NULLABLE", c.data_default AS "DATA_DEFAULT", + CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END AS "COLUMN_KEY", + cc.comments AS "COMMENT" FROM user_tab_columns c LEFT JOIN user_col_comments cc ON cc.table_name = c.table_name AND cc.column_name = c.column_name