From 56eaca9081280524dc57796348b6666ad5e8369b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Apr 2026 14:57:52 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20schema=20=E6=95=B0=E6=8D=AE=E6=BA=90=20DDL=20?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 表页入口:查看 DDL 不再依赖 dbName,支持金仓/PG 等 schema 数据源 - 标识符解析:新增 quote-safe qualified name 拆分,避免引号内点号被误拆 - DDL 兼容:PG、HighGo、VastBase 使用安全拆分处理 schema.table - 自定义驱动:补齐 custom HighGo DDL 查询时的数据库上下文 - 测试覆盖:新增 schema 表、视图 fallback、dotted 标识符等回归用例 --- .../src/components/DataGrid.layout.test.tsx | 21 +++ frontend/src/components/DataGrid.tsx | 2 +- internal/app/methods_db.go | 36 +++- .../app/methods_db_create_statement_test.go | 166 +++++++++++++++++- internal/app/methods_file.go | 4 +- internal/db/kingbase_identifier_utils.go | 123 +++++++++++++ internal/db/kingbase_identifier_utils_test.go | 24 +++ 7 files changed, 368 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 91ed218..abdf1b9 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -104,6 +104,27 @@ describe('DataGrid layout', () => { expect(tableMarkup).toContain('data-grid-ddl-action="true"'); expect(tableMarkup).toContain('查看 DDL'); + const schemaTableMarkup = renderToStaticMarkup( + , + ); + + expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"'); + expect(schemaTableMarkup).toContain('查看 DDL'); + expect(schemaTableMarkup).toContain('data-grid-page-find="true"'); + const queryMarkup = renderToStaticMarkup( = ({ const isQueryResultExport = exportScope === 'queryResult'; const canImport = exportScope === 'table' && !!tableName; const canExport = !!connectionId && (isQueryResultExport || !!tableName); - const canViewDdl = exportScope === 'table' && !!connectionId && !!dbName && !!tableName; + const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName; const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]); const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0; diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 11f557c..3cb0d48 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -184,6 +184,16 @@ func normalizeSchemaAndTableByType(dbType string, dbName string, tableName strin } } + if dbType == "postgres" || dbType == "highgo" || dbType == "vastbase" { + schema, table := db.SplitSQLQualifiedName(rawTable) + if schema != "" && table != "" { + return schema, table + } + if table != "" { + return "public", table + } + } + if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 { schema := strings.TrimSpace(parts[0]) table := strings.TrimSpace(parts[1]) @@ -214,7 +224,7 @@ func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbN if strings.EqualFold(strings.TrimSpace(config.Type), "custom") { // custom 连接的 dbName 语义依赖 driver,尽量在常见驱动上对齐内置类型行为。 switch dbType { - case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng", "clickhouse": + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "dameng", "clickhouse": if strings.TrimSpace(dbName) != "" { runConfig.Database = strings.TrimSpace(dbName) } @@ -928,6 +938,12 @@ func resolveCreateStatementWithFallback(dbInst db.Database, config connection.Co return sqlStr, nil } + if supportsViewCreateStatementLookup(dbType) { + if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok { + return viewDDL, nil + } + } + if !supportsCreateStatementFallback(dbType) { if sourceErr != nil { return "", sourceErr @@ -962,6 +978,15 @@ func supportsCreateStatementFallback(dbType string) bool { } } +func supportsViewCreateStatementLookup(dbType string) bool { + switch dbType { + case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "oracle", "dameng", "sqlite", "duckdb", "clickhouse": + return true + default: + return false + } +} + func shouldFallbackCreateStatement(dbType string, ddl string) bool { if !supportsCreateStatementFallback(dbType) { return false @@ -971,7 +996,7 @@ func shouldFallbackCreateStatement(dbType string, ddl string) bool { if trimmed == "" { return true } - if hasCreateTableHead(trimmed) { + if hasCreateTableOrViewHead(trimmed) { return false } @@ -984,7 +1009,7 @@ func shouldFallbackCreateStatement(dbType string, ddl string) bool { return true } -func hasCreateTableHead(sqlText string) bool { +func hasCreateTableOrViewHead(sqlText string) bool { lines := strings.Split(sqlText, "\n") for _, line := range lines { line = strings.TrimSpace(line) @@ -994,7 +1019,10 @@ func hasCreateTableHead(sqlText string) bool { if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { continue } - return strings.HasPrefix(strings.ToLower(line), "create table") + lower := strings.ToLower(line) + return strings.HasPrefix(lower, "create table") || + strings.HasPrefix(lower, "create view") || + strings.HasPrefix(lower, "create or replace view") } return false } diff --git a/internal/app/methods_db_create_statement_test.go b/internal/app/methods_db_create_statement_test.go index dfbf1fd..1e6b5d2 100644 --- a/internal/app/methods_db_create_statement_test.go +++ b/internal/app/methods_db_create_statement_test.go @@ -13,18 +13,23 @@ type fakeCreateStatementDB struct { createErr error columns []connection.ColumnDefinition columnsErr error + queryRows []map[string]interface{} + queryErr error createSchema string createTable string colsSchema string colsTable string + columnsCalls int + queries []string } func (f *fakeCreateStatementDB) Connect(config connection.ConnectionConfig) error { return nil } func (f *fakeCreateStatementDB) Close() error { return nil } func (f *fakeCreateStatementDB) Ping() error { return nil } func (f *fakeCreateStatementDB) Query(query string) ([]map[string]interface{}, []string, error) { - return nil, nil, nil + f.queries = append(f.queries, query) + return f.queryRows, []string{"ddl"}, f.queryErr } func (f *fakeCreateStatementDB) Exec(query string) (int64, error) { return 0, nil } func (f *fakeCreateStatementDB) GetDatabases() ([]string, error) { return nil, nil } @@ -35,6 +40,7 @@ func (f *fakeCreateStatementDB) GetCreateStatement(dbName, tableName string) (st return f.createSQL, f.createErr } func (f *fakeCreateStatementDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + f.columnsCalls++ f.colsSchema = dbName f.colsTable = tableName return f.columns, f.columnsErr @@ -80,6 +86,46 @@ func TestResolveDDLDBType_CustomDriverAlias(t *testing.T) { } } +func TestNormalizeSchemaAndTableByType_PGLikeQuotedQualifiedName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dbType string + tableName string + wantSchema string + wantTable string + }{ + {name: "postgres quoted dots", dbType: "postgres", tableName: `"sales.schema"."order.items"`, wantSchema: "sales.schema", wantTable: "order.items"}, + {name: "highgo escaped quoted", dbType: "highgo", tableName: `\"sales\".\"orders\"`, wantSchema: "sales", wantTable: "orders"}, + {name: "vastbase quoted table only", dbType: "vastbase", tableName: `"order.items"`, wantSchema: "public", wantTable: "order.items"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotSchema, gotTable := normalizeSchemaAndTableByType(tt.dbType, "", tt.tableName) + if gotSchema != tt.wantSchema || gotTable != tt.wantTable { + t.Fatalf("normalizeSchemaAndTableByType(%q,%q)=(%q,%q),want=(%q,%q)", tt.dbType, tt.tableName, gotSchema, gotTable, tt.wantSchema, tt.wantTable) + } + }) + } +} + +func TestBuildRunConfigForDDL_CustomHighGoUsesDatabase(t *testing.T) { + t.Parallel() + + got := buildRunConfigForDDL(connection.ConnectionConfig{ + Type: "custom", + Driver: "highgo", + Database: "default_db", + }, "highgo", "target_db") + if got.Database != "target_db" { + t.Fatalf("expected custom highgo DDL database target_db, got %q", got.Database) + } +} + func TestResolveCreateStatementWithFallback_CustomKingbaseUsesPublicSchema(t *testing.T) { t.Parallel() @@ -130,6 +176,124 @@ func TestResolveCreateStatementWithFallback_KeepQualifiedSchema(t *testing.T) { } } +func TestResolveCreateStatementWithFallback_PGLikeQuotedQualifiedName(t *testing.T) { + t.Parallel() + + dbInst := &fakeCreateStatementDB{ + createSQL: "-- SHOW CREATE TABLE not fully supported for PostgreSQL in this MVP.", + columns: []connection.ColumnDefinition{ + {Name: "id", Type: "integer", Nullable: "NO", Key: "PRI"}, + }, + } + + ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{ + Type: "postgres", + }, "", `"sales.schema"."order.items"`) + if err != nil { + t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err) + } + if dbInst.createSchema != "sales.schema" || dbInst.createTable != "order.items" { + t.Fatalf("expected create target sales.schema.order.items, got %q.%q", dbInst.createSchema, dbInst.createTable) + } + if dbInst.colsSchema != "sales.schema" || dbInst.colsTable != "order.items" { + t.Fatalf("expected column target sales.schema.order.items, got %q.%q", dbInst.colsSchema, dbInst.colsTable) + } + if !strings.Contains(ddl, `CREATE TABLE "sales.schema"."order.items"`) { + t.Fatalf("expected fallback DDL with quoted dotted identifiers, got: %s", ddl) + } +} + +func TestResolveCreateStatementWithFallback_ReturnsCreateViewDirectly(t *testing.T) { + t.Parallel() + + dbInst := &fakeCreateStatementDB{ + createSQL: "CREATE VIEW sales.orders_v AS SELECT 1;", + columnsErr: errors.New("should not be called"), + } + + ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", "sales.orders_v") + if err != nil { + t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err) + } + if ddl != dbInst.createSQL { + t.Fatalf("expected original create view DDL, got: %s", ddl) + } + if dbInst.columnsCalls != 0 { + t.Fatalf("CREATE VIEW path should not call GetColumns, calls=%d", dbInst.columnsCalls) + } +} + +func TestResolveCreateStatementWithFallback_PGLikeViewHelperBeforeColumnFallback(t *testing.T) { + t.Parallel() + + dbInst := &fakeCreateStatementDB{ + createSQL: "SHOW CREATE TABLE not directly supported in PostgreSQL", + columnsErr: errors.New("should not be called"), + queryRows: []map[string]interface{}{ + {"ddl": "SELECT id FROM sales.orders"}, + }, + } + + ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", "sales.orders_v") + if err != nil { + t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err) + } + if !strings.Contains(ddl, `CREATE VIEW "sales"."orders_v" AS SELECT id FROM sales.orders`) { + t.Fatalf("expected CREATE VIEW DDL from view helper, got: %s", ddl) + } + if dbInst.columnsCalls != 0 { + t.Fatalf("view helper path should not call GetColumns, calls=%d", dbInst.columnsCalls) + } + if len(dbInst.queries) == 0 || !strings.Contains(dbInst.queries[0], "pg_get_viewdef") { + t.Fatalf("expected pg_get_viewdef query, got: %v", dbInst.queries) + } +} + +func TestResolveCreateStatementWithFallback_PGLikeViewHelperKeepsQuotedDottedName(t *testing.T) { + t.Parallel() + + dbInst := &fakeCreateStatementDB{ + createSQL: "SHOW CREATE TABLE not directly supported in PostgreSQL", + columnsErr: errors.New("should not be called"), + queryRows: []map[string]interface{}{ + {"ddl": "SELECT 1"}, + }, + } + + ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", `"sales.schema"."order.items"`) + if err != nil { + t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err) + } + if !strings.Contains(ddl, `CREATE VIEW "sales.schema"."order.items" AS SELECT 1`) { + t.Fatalf("expected CREATE VIEW DDL to keep quoted dotted identifiers, got: %s", ddl) + } + if dbInst.columnsCalls != 0 { + t.Fatalf("view helper path should not call GetColumns, calls=%d", dbInst.columnsCalls) + } +} + +func TestResolveCreateStatementWithFallback_PGLikeViewHelperMissFallsBackToColumns(t *testing.T) { + t.Parallel() + + dbInst := &fakeCreateStatementDB{ + createSQL: "SHOW CREATE TABLE not directly supported in PostgreSQL", + columns: []connection.ColumnDefinition{ + {Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}, + }, + } + + ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", "sales.orders") + if err != nil { + t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err) + } + if !strings.Contains(ddl, `CREATE TABLE "sales"."orders"`) { + t.Fatalf("expected CREATE TABLE fallback after view helper miss, got: %s", ddl) + } + if dbInst.columnsCalls != 1 { + t.Fatalf("expected one GetColumns call after view helper miss, calls=%d", dbInst.columnsCalls) + } +} + func TestResolveCreateStatementWithFallback_NoFallbackForMySQL(t *testing.T) { t.Parallel() diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 5b04ec6..2d1d0b4 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -1661,8 +1661,8 @@ func tryGetViewCreateStatement( continue } if looksLikeSelectOrWith(createSQL) { - qualifiedView := qualifyTable(schemaName, viewName) - createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteQualifiedIdentByType(config.Type, qualifiedView), strings.TrimSuffix(strings.TrimSpace(createSQL), ";")) + dbType := resolveDDLDBType(config) + createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteTableIdentByType(dbType, schemaName, viewName), strings.TrimSuffix(strings.TrimSpace(createSQL), ";")) } return ensureSQLTerminator(createSQL), true } diff --git a/internal/db/kingbase_identifier_utils.go b/internal/db/kingbase_identifier_utils.go index e8d980f..6c399d8 100644 --- a/internal/db/kingbase_identifier_utils.go +++ b/internal/db/kingbase_identifier_utils.go @@ -88,6 +88,11 @@ func SplitKingbaseQualifiedName(raw string) (schema string, table string) { return splitKingbaseQualifiedNameCommon(raw) } +// SplitSQLQualifiedName splits a schema-qualified SQL identifier without splitting dots inside quotes. +func SplitSQLQualifiedName(raw string) (schema string, table string) { + return splitSQLQualifiedNameCommon(raw) +} + func splitKingbaseQualifiedNameCommon(raw string) (schema string, table string) { text := strings.TrimSpace(raw) if text == "" { @@ -114,6 +119,124 @@ func splitKingbaseQualifiedNameCommon(raw string) (schema string, table string) return schemaPart, tablePart } +func splitSQLQualifiedNameCommon(raw string) (schema string, table string) { + text := normalizeSQLIdentifierEscapes(strings.TrimSpace(raw)) + if text == "" { + return "", "" + } + + sep := findSQLQualifiedSeparator(text) + if sep < 0 { + return "", normalizeSQLIdentPartCommon(text) + } + + schemaPart := normalizeSQLIdentPartCommon(text[:sep]) + tablePart := normalizeSQLIdentPartCommon(text[sep+1:]) + + if tablePart == "" { + if schemaPart == "" { + return "", normalizeSQLIdentPartCommon(text) + } + return "", schemaPart + } + if schemaPart == "" { + return "", tablePart + } + return schemaPart, tablePart +} + +func normalizeSQLIdentifierEscapes(raw string) string { + value := strings.TrimSpace(raw) + for i := 0; i < 4; i++ { + next := strings.TrimSpace(value) + next = strings.ReplaceAll(next, `\\\"`, `\"`) + next = strings.ReplaceAll(next, `\"`, `"`) + if next == value { + break + } + value = next + } + return strings.TrimSpace(value) +} + +func normalizeSQLIdentPartCommon(raw string) string { + value := normalizeSQLIdentifierEscapes(strings.TrimSpace(raw)) + if value == "" { + return "" + } + if len(value) >= 2 { + first := value[0] + last := value[len(value)-1] + switch { + case first == '"' && last == '"': + return strings.TrimSpace(strings.ReplaceAll(value[1:len(value)-1], `""`, `"`)) + case first == '`' && last == '`': + return strings.TrimSpace(strings.ReplaceAll(value[1:len(value)-1], "``", "`")) + case first == '[' && last == ']': + return strings.TrimSpace(strings.ReplaceAll(value[1:len(value)-1], "]]", "]")) + } + } + return value +} + +func findSQLQualifiedSeparator(raw string) int { + inDouble := false + inBacktick := false + inBracket := false + + for i := 0; i < len(raw); i++ { + ch := raw[i] + + if inDouble { + if ch == '\\' && i+1 < len(raw) && raw[i+1] == '"' { + inDouble = false + i++ + continue + } + if ch == '"' { + if i+1 < len(raw) && raw[i+1] == '"' { + i++ + continue + } + inDouble = false + } + continue + } + + if inBacktick { + if ch == '`' { + inBacktick = false + } + continue + } + + if inBracket { + if ch == ']' { + inBracket = false + } + continue + } + + switch ch { + case '\\': + if i+1 < len(raw) && raw[i+1] == '"' { + inDouble = true + i++ + } + case '"': + inDouble = true + case '`': + inBacktick = true + case '[': + inBracket = true + case '.': + return i + } + } + + return -1 +} + func findKingbaseQualifiedSeparator(raw string) int { inDouble := false inBacktick := false diff --git a/internal/db/kingbase_identifier_utils_test.go b/internal/db/kingbase_identifier_utils_test.go index 7e8cec8..693c3e9 100644 --- a/internal/db/kingbase_identifier_utils_test.go +++ b/internal/db/kingbase_identifier_utils_test.go @@ -51,6 +51,30 @@ func TestSplitKingbaseQualifiedNameCommon(t *testing.T) { } } +func TestSplitSQLQualifiedName(t *testing.T) { + tests := []struct { + name string + in string + wantSchema string + wantTable string + }{ + {name: "plain", in: "sales.orders", wantSchema: "sales", wantTable: "orders"}, + {name: "quoted dots", in: `"sales.schema"."order.items"`, wantSchema: "sales.schema", wantTable: "order.items"}, + {name: "escaped quoted dots", in: `\"sales.schema\".\"order.items\"`, wantSchema: "sales.schema", wantTable: "order.items"}, + {name: "quoted table only with dot", in: `"order.items"`, wantSchema: "", wantTable: "order.items"}, + {name: "escaped quoted", in: `\"sales\".\"orders\"`, wantSchema: "sales", wantTable: "orders"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSchema, gotTable := SplitSQLQualifiedName(tt.in) + if gotSchema != tt.wantSchema || gotTable != tt.wantTable { + t.Fatalf("SplitSQLQualifiedName(%q)=(%q,%q),want=(%q,%q)", tt.in, gotSchema, gotTable, tt.wantSchema, tt.wantTable) + } + }) + } +} + func TestBuildKingbaseSearchPathCommon(t *testing.T) { tests := []struct { name string