diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 7eb84e8..3ad5db5 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -741,6 +741,15 @@ describe('QueryEditor external SQL save', () => { }); expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL'); + const FakeNode = class {}; + const bodyNode = new FakeNode(); + const documentElement = new FakeNode(); + vi.stubGlobal('Node', FakeNode); + vi.stubGlobal('document', { + body: bodyNode, + documentElement, + }); + editorState.hasTextFocus = false; const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`); const toggleEvent = { ctrlKey: !isMacRuntime, @@ -748,7 +757,7 @@ describe('QueryEditor external SQL save', () => { altKey: false, shiftKey: true, key: 'm', - target: null, + target: bodyNode, preventDefault: vi.fn(), stopPropagation: vi.fn(), }; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index f8e06d6..e04a850 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1766,6 +1766,16 @@ const resolveEventTargetNode = (target: EventTarget | null): Node | null => ( typeof Node !== 'undefined' && target instanceof Node ? target : null ); +const isDocumentLevelShortcutTarget = (targetNode: Node | null): boolean => { + if (!targetNode) { + return true; + } + if (typeof document === 'undefined') { + return false; + } + return targetNode === document.body || targetNode === document.documentElement; +}; + const clearQueryEditorLinkDecorations = ( editor: any, decorationIdsRef: React.MutableRefObject, @@ -5004,7 +5014,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor?.hasTextFocus?.(); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); - if (!editorHasFocus && !inQueryEditor) { + if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { return; } diff --git a/frontend/src/utils/columnDefinition.test.ts b/frontend/src/utils/columnDefinition.test.ts index e8d00fb..43fea31 100644 --- a/frontend/src/utils/columnDefinition.test.ts +++ b/frontend/src/utils/columnDefinition.test.ts @@ -60,6 +60,21 @@ describe('columnDefinition metadata normalization', () => { }); }); + it('normalizes Dameng style data length and nullable flags', () => { + const column = { + COLUMN_NAME: 'USER_NAME', + DATA_TYPE: 'VARCHAR2', + DATA_LENGTH: 64, + NULLABLE: 'N', + }; + + expect(normalizeColumnDefinition(column)).toMatchObject({ + name: 'USER_NAME', + type: 'VARCHAR2(64)', + nullable: 'NO', + }); + }); + it('maps boolean primary and unique metadata aliases to GoNavi keys', () => { expect(getColumnDefinitionKey({ column_name: 'id', isPrimary: true })).toBe('PRI'); expect(getColumnDefinitionKey({ column_name: 'id', primary_key: 't' })).toBe('PRI'); diff --git a/frontend/src/utils/columnDefinition.ts b/frontend/src/utils/columnDefinition.ts index 22d9c12..b67e381 100644 --- a/frontend/src/utils/columnDefinition.ts +++ b/frontend/src/utils/columnDefinition.ts @@ -55,6 +55,19 @@ const readNumberProperty = (value: unknown, keys: string[]): number => { return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0; }; +const normalizeNullable = (value: string): string => { + const normalized = String(value || '').trim(); + if (!normalized) return ''; + const upper = normalized.toUpperCase(); + if (upper === 'N' || upper === 'NO' || upper === 'FALSE' || upper === '0' || upper === 'NOT NULL') { + return 'NO'; + } + if (upper === 'Y' || upper === 'YES' || upper === 'TRUE' || upper === '1' || upper === 'NULL' || upper === 'NULLABLE') { + return 'YES'; + } + return normalized; +}; + export const getColumnDefinitionName = (column: unknown): string => ( readStringProperty(column, ['name', 'Name', 'COLUMN_NAME', 'column_name', 'field', 'Field']) ); @@ -85,6 +98,8 @@ export const getColumnDefinitionType = (column: unknown): string => { 'character_max_length', 'CHAR_LENGTH', 'char_length', + 'DATA_LENGTH', + 'data_length', 'LENGTH', 'length', ]); @@ -148,7 +163,7 @@ export const normalizeColumnDefinition = (column: unknown): ColumnDefinition => ...source, name: getColumnDefinitionName(column), type: getColumnDefinitionType(column), - nullable: readStringProperty(column, ['nullable', 'Nullable', 'NULLABLE', 'is_nullable', 'IS_NULLABLE', 'Null', 'null']), + nullable: normalizeNullable(readStringProperty(column, ['nullable', 'Nullable', 'NULLABLE', 'is_nullable', 'IS_NULLABLE', 'Null', 'null'])), key: getColumnDefinitionKey(column), default: source.default, extra: getColumnDefinitionExtra(column), diff --git a/internal/db/dameng_columns_test.go b/internal/db/dameng_columns_test.go index e1ad3c6..104ac6b 100644 --- a/internal/db/dameng_columns_test.go +++ b/internal/db/dameng_columns_test.go @@ -15,6 +15,11 @@ func TestBuildDamengColumnsQuery_IncludesPrimaryKeyMetadata(t *testing.T) { if !strings.Contains(ownerQuery, "AS column_key") { t.Fatalf("owner query 应返回 column_key, got=%s", ownerQuery) } + for _, want := range []string{"c.data_length", "c.char_length", "c.data_precision", "c.data_scale"} { + if !strings.Contains(ownerQuery, want) { + t.Fatalf("owner query 应返回字段类型长度信息 %q, got=%s", want, ownerQuery) + } + } if !strings.Contains(ownerQuery, "WHERE c.owner = 'BIZ' AND c.table_name = 'ORDERS'") { t.Fatalf("owner query 应按 owner/table 过滤, got=%s", ownerQuery) } @@ -42,22 +47,36 @@ func TestBuildDamengColumnDefinitions_MarksPrimaryKeyColumns(t *testing.T) { { "COLUMN_NAME": "NAME", "DATA_TYPE": "VARCHAR2", + "DATA_LENGTH": 128, + "CHAR_LENGTH": 64, "NULLABLE": "Y", "DATA_DEFAULT": "guest", "COLUMN_KEY": "", }, + { + "COLUMN_NAME": "AMOUNT", + "DATA_TYPE": "NUMBER", + "DATA_PRECISION": 10, + "DATA_SCALE": 2, + "NULLABLE": "N", + "DATA_DEFAULT": nil, + "COLUMN_KEY": "", + }, }) - if len(columns) != 2 { + if len(columns) != 3 { t.Fatalf("unexpected column count: %d", len(columns)) } - if columns[0].Name != "ID" || columns[0].Key != "PRI" { + if columns[0].Name != "ID" || columns[0].Key != "PRI" || columns[0].Nullable != "NO" { t.Fatalf("主键列未正确标记: %+v", columns[0]) } - if columns[1].Name != "NAME" || columns[1].Key != "" { + if columns[1].Name != "NAME" || columns[1].Type != "VARCHAR2(64)" || columns[1].Nullable != "YES" || columns[1].Key != "" { t.Fatalf("非主键列标记异常: %+v", columns[1]) } if columns[1].Default == nil || *columns[1].Default != "guest" { t.Fatalf("默认值未保留: %+v", columns[1]) } + if columns[2].Name != "AMOUNT" || columns[2].Type != "NUMBER(10,2)" || columns[2].Nullable != "NO" { + t.Fatalf("数值字段定义异常: %+v", columns[2]) + } } diff --git a/internal/db/dameng_metadata.go b/internal/db/dameng_metadata.go index 2492a85..f06ab45 100644 --- a/internal/db/dameng_metadata.go +++ b/internal/db/dameng_metadata.go @@ -110,7 +110,7 @@ func buildDamengColumnsQuery(dbName, tableName string) string { upperDBName := strings.ToUpper(strings.TrimSpace(dbName)) if upperDBName == "" { - return fmt.Sprintf(`SELECT c.column_name, c.data_type, c.nullable, c.data_default, + return fmt.Sprintf(`SELECT c.column_name, c.data_type, c.data_length, c.char_length, c.data_precision, c.data_scale, c.nullable, c.data_default, CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END AS column_key FROM user_tab_columns c LEFT JOIN ( @@ -123,7 +123,7 @@ func buildDamengColumnsQuery(dbName, tableName string) string { ORDER BY c.column_id`, upperTableName) } - return fmt.Sprintf(`SELECT c.column_name, c.data_type, c.nullable, c.data_default, + return fmt.Sprintf(`SELECT c.column_name, c.data_type, c.data_length, c.char_length, c.data_precision, c.data_scale, c.nullable, c.data_default, CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END AS column_key FROM all_tab_columns c LEFT JOIN ( @@ -137,13 +137,77 @@ func buildDamengColumnsQuery(dbName, tableName string) string { ORDER BY c.column_id`, upperDBName, upperTableName) } +func getDamengRowInt(row map[string]interface{}, keys ...string) (int, bool) { + for _, key := range keys { + raw := getDamengRowString(row, key) + if raw == "" { + continue + } + var parsed int + if _, err := fmt.Sscanf(raw, "%d", &parsed); err == nil { + return parsed, true + } + } + return 0, false +} + +func normalizeDamengNullable(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "N", "NO", "FALSE", "0", "NOT NULL": + return "NO" + case "Y", "YES", "TRUE", "1", "NULL", "NULLABLE": + return "YES" + default: + return strings.TrimSpace(value) + } +} + +func isDamengLengthQualifiedType(upperType string) bool { + switch strings.TrimSpace(upperType) { + case "CHAR", "NCHAR", "VARCHAR", "VARCHAR2", "NVARCHAR", "NVARCHAR2", "RAW", "BINARY", "VARBINARY": + return true + default: + return strings.Contains(upperType, "CHARACTER") + } +} + +func formatDamengColumnType(row map[string]interface{}) string { + dataType := getDamengRowString(row, "DATA_TYPE") + if dataType == "" || strings.Contains(dataType, "(") { + return dataType + } + + upperType := strings.ToUpper(dataType) + if isDamengLengthQualifiedType(upperType) { + if charLength, ok := getDamengRowInt(row, "CHAR_LENGTH", "CHAR_COL_DECL_LENGTH"); ok && charLength > 0 { + return fmt.Sprintf("%s(%d)", dataType, charLength) + } + if dataLength, ok := getDamengRowInt(row, "DATA_LENGTH"); ok && dataLength > 0 { + return fmt.Sprintf("%s(%d)", dataType, dataLength) + } + } + + if strings.Contains(upperType, "NUMBER") || strings.Contains(upperType, "DECIMAL") || strings.Contains(upperType, "NUMERIC") { + precision, hasPrecision := getDamengRowInt(row, "DATA_PRECISION", "NUMERIC_PRECISION") + if hasPrecision && precision > 0 { + scale, hasScale := getDamengRowInt(row, "DATA_SCALE", "NUMERIC_SCALE") + if hasScale && scale > 0 { + return fmt.Sprintf("%s(%d,%d)", dataType, precision, scale) + } + return fmt.Sprintf("%s(%d)", dataType, precision) + } + } + + return dataType +} + func buildDamengColumnDefinitions(data []map[string]interface{}) []connection.ColumnDefinition { columns := make([]connection.ColumnDefinition, 0, len(data)) for _, row := range data { col := connection.ColumnDefinition{ Name: getDamengRowString(row, "COLUMN_NAME"), - Type: getDamengRowString(row, "DATA_TYPE"), - Nullable: getDamengRowString(row, "NULLABLE"), + Type: formatDamengColumnType(row), + Nullable: normalizeDamengNullable(getDamengRowString(row, "NULLABLE")), Key: getDamengRowString(row, "COLUMN_KEY"), } diff --git a/internal/db/oracle_get_tables_test.go b/internal/db/oracle_get_tables_test.go index 97608a4..093be14 100644 --- a/internal/db/oracle_get_tables_test.go +++ b/internal/db/oracle_get_tables_test.go @@ -104,13 +104,59 @@ 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"`} { + for _, want := range []string{`AS "COLUMN_NAME"`, `AS "DATA_TYPE"`, `AS "DATA_LENGTH"`, `AS "CHAR_LENGTH"`, `AS "DATA_PRECISION"`, `AS "DATA_SCALE"`, `AS "COMMENT"`} { if !strings.Contains(queries[0], want) { t.Fatalf("expected GetColumns query to contain stable alias %q, got %s", want, queries[0]) } } } +func TestFormatOracleColumnTypeIncludesLengthAndPrecision(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + row map[string]interface{} + want string + }{ + { + name: "varchar2 char length", + row: map[string]interface{}{ + "DATA_TYPE": "VARCHAR2", + "DATA_LENGTH": 256, + "CHAR_LENGTH": 128, + }, + want: "VARCHAR2(128)", + }, + { + name: "number precision scale", + row: map[string]interface{}{ + "DATA_TYPE": "NUMBER", + "DATA_PRECISION": 10, + "DATA_SCALE": 2, + }, + want: "NUMBER(10,2)", + }, + { + name: "date remains plain", + row: map[string]interface{}{ + "DATA_TYPE": "DATE", + }, + want: "DATE", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := formatOracleColumnType(tc.row); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + func TestOracleGetCreateStatementAppendsTableAndColumnComments(t *testing.T) { t.Parallel() diff --git a/internal/db/oracle_impl.go b/internal/db/oracle_impl.go index d5ad107..00a8dbf 100644 --- a/internal/db/oracle_impl.go +++ b/internal/db/oracle_impl.go @@ -325,7 +325,7 @@ 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 AS "COLUMN_NAME", c.data_type AS "DATA_TYPE", c.nullable AS "NULLABLE", c.data_default AS "DATA_DEFAULT", + query := fmt.Sprintf(`SELECT c.column_name AS "COLUMN_NAME", c.data_type AS "DATA_TYPE", c.data_length AS "DATA_LENGTH", c.char_length AS "CHAR_LENGTH", c.data_precision AS "DATA_PRECISION", c.data_scale AS "DATA_SCALE", 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 @@ -342,7 +342,7 @@ 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 AS "COLUMN_NAME", c.data_type AS "DATA_TYPE", c.nullable AS "NULLABLE", c.data_default AS "DATA_DEFAULT", + query = fmt.Sprintf(`SELECT c.column_name AS "COLUMN_NAME", c.data_type AS "DATA_TYPE", c.data_length AS "DATA_LENGTH", c.char_length AS "CHAR_LENGTH", c.data_precision AS "DATA_PRECISION", c.data_scale AS "DATA_SCALE", 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 @@ -367,7 +367,7 @@ func (o *OracleDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi for _, row := range data { col := connection.ColumnDefinition{ Name: oracleRowString(row, "COLUMN_NAME"), - Type: oracleRowString(row, "DATA_TYPE"), + Type: formatOracleColumnType(row), Nullable: oracleRowString(row, "NULLABLE"), Key: oracleRowString(row, "COLUMN_KEY"), Comment: oracleRowString(row, "COMMENT"), @@ -402,7 +402,58 @@ func oracleRowString(row map[string]interface{}, names ...string) string { if value == nil { return "" } - return fmt.Sprintf("%v", value) + return strings.TrimSpace(fmt.Sprintf("%v", value)) +} + +func oracleRowInt(row map[string]interface{}, names ...string) (int, bool) { + raw := oracleRowString(row, names...) + if raw == "" { + return 0, false + } + parsed, err := strconv.Atoi(raw) + if err != nil { + return 0, false + } + return parsed, true +} + +func isOracleLengthQualifiedType(upperType string) bool { + switch strings.TrimSpace(upperType) { + case "CHAR", "NCHAR", "VARCHAR", "VARCHAR2", "NVARCHAR", "NVARCHAR2", "RAW", "BINARY", "VARBINARY": + return true + default: + return strings.Contains(upperType, "CHARACTER") + } +} + +func formatOracleColumnType(row map[string]interface{}) string { + dataType := oracleRowString(row, "DATA_TYPE") + if dataType == "" || strings.Contains(dataType, "(") { + return dataType + } + + upperType := strings.ToUpper(dataType) + if isOracleLengthQualifiedType(upperType) { + if charLength, ok := oracleRowInt(row, "CHAR_LENGTH", "CHAR_COL_DECL_LENGTH"); ok && charLength > 0 { + return fmt.Sprintf("%s(%d)", dataType, charLength) + } + if dataLength, ok := oracleRowInt(row, "DATA_LENGTH"); ok && dataLength > 0 { + return fmt.Sprintf("%s(%d)", dataType, dataLength) + } + } + + if strings.Contains(upperType, "NUMBER") || strings.Contains(upperType, "DECIMAL") || strings.Contains(upperType, "NUMERIC") { + precision, hasPrecision := oracleRowInt(row, "DATA_PRECISION", "NUMERIC_PRECISION") + if hasPrecision && precision > 0 { + scale, hasScale := oracleRowInt(row, "DATA_SCALE", "NUMERIC_SCALE") + if hasScale && scale > 0 { + return fmt.Sprintf("%s(%d,%d)", dataType, precision, scale) + } + return fmt.Sprintf("%s(%d)", dataType, precision) + } + } + + return dataType } func (o *OracleDB) appendOracleCommentDDL(baseDDL string, dbName string, tableName string) string {