🐛 fix(data-grid): 修复 schema 数据源 DDL 查看异常

- 表页入口:查看 DDL 不再依赖 dbName,支持金仓/PG 等 schema 数据源
- 标识符解析:新增 quote-safe qualified name 拆分,避免引号内点号被误拆
- DDL 兼容:PG、HighGo、VastBase 使用安全拆分处理 schema.table
- 自定义驱动:补齐 custom HighGo DDL 查询时的数据库上下文
- 测试覆盖:新增 schema 表、视图 fallback、dotted 标识符等回归用例
This commit is contained in:
Syngnat
2026-04-28 14:57:52 +08:00
parent 51675f9d05
commit 56eaca9081
7 changed files with 368 additions and 8 deletions

View File

@@ -104,6 +104,27 @@ describe('DataGrid layout', () => {
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
const schemaTableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="public.users"
dbName=""
connectionId="conn-1"
/>,
);
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
expect(schemaTableMarkup).toContain('查看 DDL');
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
const queryMarkup = renderToStaticMarkup(
<DataGrid
data={[

View File

@@ -1133,7 +1133,7 @@ const DataGrid: React.FC<DataGridProps> = ({
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;

View File

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

View File

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

View File

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

View File

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

View File

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