🐛 fix(workbench): 修复结果区切换与对象设计字段定义

- 修复手动隐藏结果区后快捷键无法再次显示的问题

- 补全 Dameng/Oracle-like 字段长度、精度和可空标识

- 增加 SQL 编辑器、字段元数据和对象设计回归测试
This commit is contained in:
Syngnat
2026-06-10 15:51:03 +08:00
parent 1d1d8d21cd
commit c9d0bce153
8 changed files with 244 additions and 15 deletions

View File

@@ -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(),
};

View File

@@ -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<string[]>,
@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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