🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性
This commit is contained in:
Syngnat
2026-03-02 14:18:44 +08:00
parent 84688e995a
commit 3ca898a950
15 changed files with 672 additions and 71 deletions

View File

@@ -225,7 +225,7 @@ const builtinDriverManifestJSON = `{
"vastbase": { "engine": "go", "version": "1.11.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/vastbase" },
"mongodb": { "engine": "go", "version": "2.5.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mongodb" },
"tdengine": { "engine": "go", "version": "3.7.8", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" },
"clickhouse": { "engine": "go", "version": "2.43.0", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" }
"clickhouse": { "engine": "go", "version": "2.43.1", "checksumPolicy": "off", "downloadUrl": "builtin://activate/clickhouse" }
}
}`
@@ -278,7 +278,7 @@ var latestDriverVersionMap = map[string]string{
"vastbase": "1.11.2",
"mongodb": "2.5.0",
"tdengine": "3.7.8",
"clickhouse": "2.43.0",
"clickhouse": "2.43.1",
"oracle": "2.9.0",
"postgres": "1.11.2",
"redis": "9.17.3",

View File

@@ -2,6 +2,7 @@ package app
import (
"bufio"
"context"
"encoding/csv"
"encoding/json"
"fmt"
@@ -16,11 +17,16 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/utils"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/xuri/excelize/v2"
)
const minExportQueryTimeout = 5 * time.Minute
const minClickHouseExportQueryTimeout = 2 * time.Hour
func (a *App) OpenSQLFile() connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select SQL File",
@@ -614,7 +620,7 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName))
data, columns, err := dbInst.Query(query)
data, columns, err := queryDataForExport(dbInst, runConfig, query)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
@@ -945,7 +951,7 @@ func listViewNameLookup(dbInst db.Database, config connection.ConnectionConfig,
if strings.TrimSpace(query) == "" {
continue
}
rows, _, err := dbInst.Query(query)
rows, _, err := queryDataForExport(dbInst, config, query)
if err != nil {
continue
}
@@ -1056,7 +1062,7 @@ func tryGetViewCreateStatement(
if strings.TrimSpace(query) == "" {
continue
}
rows, _, err := dbInst.Query(query)
rows, _, err := queryDataForExport(dbInst, config, query)
if err != nil || len(rows) == 0 {
continue
}
@@ -1421,7 +1427,7 @@ func dumpTableSQL(
qualified := qualifyTable(schemaName, pureTableName)
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
data, columns, err := dbInst.Query(selectSQL)
data, columns, err := queryDataForExport(dbInst, config, selectSQL)
if err != nil {
return err
}
@@ -1456,14 +1462,17 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
if defaultName == "" {
defaultName = "export"
}
logger.Infof("ExportData 开始rows=%d cols=%d format=%s defaultName=%s", len(data), len(columns), strings.ToLower(strings.TrimSpace(format)), strings.TrimSpace(defaultName))
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export Data",
DefaultFilename: fmt.Sprintf("%s.%s", defaultName, strings.ToLower(format)),
})
if err != nil || filename == "" {
logger.Infof("ExportData 已取消或未选择文件err=%v", err)
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
logger.Infof("ExportData 选定文件:%s", filename)
f, err := os.Create(filename)
if err != nil {
@@ -1471,9 +1480,11 @@ func (a *App) ExportData(data []map[string]interface{}, columns []string, defaul
}
defer f.Close()
if err := writeRowsToFile(f, data, columns, format); err != nil {
logger.Warnf("ExportData 写入失败file=%s err=%v", filename, err)
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
}
logger.Infof("ExportData 完成file=%s rows=%d", filename, len(data))
return connection.QueryResult{Success: true, Message: "Export successful"}
}
@@ -1494,8 +1505,10 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
DefaultFilename: fmt.Sprintf("%s.%s", defaultName, strings.ToLower(format)),
})
if err != nil || filename == "" {
logger.Infof("ExportQuery 已取消或未选择文件err=%v", err)
return connection.QueryResult{Success: false, Message: "Cancelled"}
}
logger.Infof("ExportQuery 开始type=%s db=%s format=%s file=%s sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), strings.ToLower(strings.TrimSpace(format)), filename, sqlSnippet(query))
runConfig := normalizeRunConfig(config, dbName)
dbInst, err := a.getDatabase(runConfig)
@@ -1509,8 +1522,9 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
return connection.QueryResult{Success: false, Message: "Only SELECT/WITH queries are supported"}
}
data, columns, err := dbInst.Query(query)
data, columns, err := queryDataForExport(dbInst, runConfig, query)
if err != nil {
logger.Warnf("ExportQuery 查询失败type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query))
return connection.QueryResult{Success: false, Message: err.Error()}
}
@@ -1521,12 +1535,55 @@ func (a *App) ExportQuery(config connection.ConnectionConfig, dbName string, que
defer f.Close()
if err := writeRowsToFile(f, data, columns, format); err != nil {
logger.Warnf("ExportQuery 写入失败file=%s err=%v", filename, err)
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
}
logger.Infof("ExportQuery 完成file=%s rows=%d cols=%d", filename, len(data), len(columns))
return connection.QueryResult{Success: true, Message: "Export successful"}
}
func queryDataForExport(dbInst db.Database, config connection.ConnectionConfig, query string) ([]map[string]interface{}, []string, error) {
timeout := getExportQueryTimeout(config)
dbType := resolveDDLDBType(config)
if dbType == "clickhouse" {
logger.Infof("ClickHouse 导出查询开始timeout=%s SQL片段=%q", timeout, sqlSnippet(query))
}
if q, ok := dbInst.(interface {
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
}); ok {
ctx, cancel := utils.ContextWithTimeout(timeout)
defer cancel()
data, columns, err := q.QueryContext(ctx, query)
if err != nil && dbType == "clickhouse" {
logger.Warnf("ClickHouse 导出查询失败timeout=%s SQL片段=%q err=%v", timeout, sqlSnippet(query), err)
}
return data, columns, err
}
data, columns, err := dbInst.Query(query)
if err != nil && dbType == "clickhouse" {
logger.Warnf("ClickHouse 导出查询失败(无 QueryContexttimeout=%s SQL片段=%q err=%v", timeout, sqlSnippet(query), err)
}
return data, columns, err
}
func getExportQueryTimeout(config connection.ConnectionConfig) time.Duration {
timeout := time.Duration(config.Timeout) * time.Second
if timeout <= 0 {
timeout = minExportQueryTimeout
}
if resolveDDLDBType(config) == "clickhouse" {
if timeout < minClickHouseExportQueryTimeout {
timeout = minClickHouseExportQueryTimeout
}
return timeout
}
if timeout < minExportQueryTimeout {
timeout = minExportQueryTimeout
}
return timeout
}
func writeRowsToFile(f *os.File, data []map[string]interface{}, columns []string, format string) error {
format = strings.ToLower(strings.TrimSpace(format))
if f == nil {

View File

@@ -2,12 +2,65 @@ package app
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
type fakeExportQueryDB struct {
data []map[string]interface{}
cols []string
err error
lastQuery string
lastContextTimeout time.Duration
hasContextDeadline bool
}
func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil }
func (f *fakeExportQueryDB) Close() error { return nil }
func (f *fakeExportQueryDB) Ping() error { return nil }
func (f *fakeExportQueryDB) Query(query string) ([]map[string]interface{}, []string, error) {
f.lastQuery = query
return f.data, f.cols, f.err
}
func (f *fakeExportQueryDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
f.lastQuery = query
if deadline, ok := ctx.Deadline(); ok {
f.hasContextDeadline = true
f.lastContextTimeout = time.Until(deadline)
}
return f.data, f.cols, f.err
}
func (f *fakeExportQueryDB) Exec(query string) (int64, error) { return 0, nil }
func (f *fakeExportQueryDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *fakeExportQueryDB) GetTables(dbName string) ([]string, error) {
return nil, nil
}
func (f *fakeExportQueryDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *fakeExportQueryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *fakeExportQueryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeExportQueryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeExportQueryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeExportQueryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) {
got := formatExportCellText(1.445663e+06)
if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") {
@@ -87,3 +140,66 @@ func TestWriteRowsToFile_JSON_NumberKeepPlainText(t *testing.T) {
t.Fatalf("json 数值格式异常want=1445663 got=%s", decoded[0]["id"].String())
}
}
func TestQueryDataForExport_UsesMinimumTimeout(t *testing.T) {
fake := &fakeExportQueryDB{
data: []map[string]interface{}{{"v": 1}},
cols: []string{"v"},
}
_, _, err := queryDataForExport(fake, connection.ConnectionConfig{Timeout: 10}, "SELECT 1")
if err != nil {
t.Fatalf("queryDataForExport 返回错误: %v", err)
}
if !fake.hasContextDeadline {
t.Fatal("queryDataForExport 应设置 context deadline")
}
if fake.lastQuery != "SELECT 1" {
t.Fatalf("queryDataForExport 查询语句异常want=%q got=%q", "SELECT 1", fake.lastQuery)
}
lowerBound := minExportQueryTimeout - 5*time.Second
upperBound := minExportQueryTimeout + 5*time.Second
if fake.lastContextTimeout < lowerBound || fake.lastContextTimeout > upperBound {
t.Fatalf("导出最小超时异常want≈%s got=%s", minExportQueryTimeout, fake.lastContextTimeout)
}
}
func TestQueryDataForExport_UsesLargerConfiguredTimeout(t *testing.T) {
fake := &fakeExportQueryDB{
data: []map[string]interface{}{{"v": 1}},
cols: []string{"v"},
}
_, _, err := queryDataForExport(fake, connection.ConnectionConfig{Timeout: 900}, "SELECT 1")
if err != nil {
t.Fatalf("queryDataForExport 返回错误: %v", err)
}
if !fake.hasContextDeadline {
t.Fatal("queryDataForExport 应设置 context deadline")
}
expected := 900 * time.Second
lowerBound := expected - 5*time.Second
upperBound := expected + 5*time.Second
if fake.lastContextTimeout < lowerBound || fake.lastContextTimeout > upperBound {
t.Fatalf("导出配置超时异常want≈%s got=%s", expected, fake.lastContextTimeout)
}
}
func TestGetExportQueryTimeout_ClickHouseUsesLongerMinimum(t *testing.T) {
timeout := getExportQueryTimeout(connection.ConnectionConfig{
Type: "clickhouse",
Timeout: 30,
})
if timeout != minClickHouseExportQueryTimeout {
t.Fatalf("clickhouse 导出超时下限异常want=%s got=%s", minClickHouseExportQueryTimeout, timeout)
}
}
func TestGetExportQueryTimeout_CustomClickHouseUsesLongerMinimum(t *testing.T) {
timeout := getExportQueryTimeout(connection.ConnectionConfig{
Type: "custom",
Driver: "clickhouse",
Timeout: 30,
})
if timeout != minClickHouseExportQueryTimeout {
t.Fatalf("custom clickhouse 导出超时下限异常want=%s got=%s", minClickHouseExportQueryTimeout, timeout)
}
}

View File

@@ -24,6 +24,7 @@ const (
defaultClickHousePort = 9000
defaultClickHouseUser = "default"
defaultClickHouseDatabase = "default"
minClickHouseReadTimeout = 5 * time.Minute
)
type ClickHouseDB struct {
@@ -101,7 +102,11 @@ func applyClickHouseURI(config connection.ConnectionConfig) connection.Connectio
}
func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig) *clickhouse.Options {
timeout := getConnectTimeout(config)
connectTimeout := getConnectTimeout(config)
readTimeout := connectTimeout
if readTimeout < minClickHouseReadTimeout {
readTimeout = minClickHouseReadTimeout
}
return &clickhouse.Options{
Addr: []string{
net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
@@ -111,8 +116,8 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
Username: strings.TrimSpace(config.User),
Password: config.Password,
},
DialTimeout: timeout,
ReadTimeout: timeout,
DialTimeout: connectTimeout,
ReadTimeout: readTimeout,
}
}

View File

@@ -147,7 +147,7 @@ func TestClickHouseOptions_UsesStructuredTimeoutAndAuth(t *testing.T) {
if opts.DialTimeout != 15*time.Second {
t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout)
}
if opts.ReadTimeout != 15*time.Second {
if opts.ReadTimeout != minClickHouseReadTimeout {
t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout)
}
if _, ok := opts.Settings["write_timeout"]; ok {
@@ -160,3 +160,27 @@ func TestClickHouseOptions_UsesStructuredTimeoutAndAuth(t *testing.T) {
t.Fatalf("options 不应通过 settings 传递 dial_timeout%v", opts.Settings)
}
}
func TestClickHouseOptions_ReadTimeoutUsesLargerConfiguredTimeout(t *testing.T) {
c := &ClickHouseDB{}
cfg := normalizeClickHouseConfig(connection.ConnectionConfig{
Type: "clickhouse",
Host: "127.0.0.1",
Port: 9000,
User: "default",
Password: "secret",
Database: "analytics",
Timeout: 900,
})
opts := c.buildClickHouseOptions(cfg)
if opts == nil {
t.Fatal("options 为空")
}
if opts.DialTimeout != 900*time.Second {
t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout)
}
if opts.ReadTimeout != 900*time.Second {
t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout)
}
}

View File

@@ -11,8 +11,10 @@ import (
"os/exec"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
)
const (
@@ -38,6 +40,7 @@ type optionalAgentRequest struct {
Method string `json:"method"`
Config *connection.ConnectionConfig `json:"config,omitempty"`
Query string `json:"query,omitempty"`
TimeoutMs int64 `json:"timeoutMs,omitempty"`
DBName string `json:"dbName,omitempty"`
TableName string `json:"tableName,omitempty"`
Changes *connection.ChangeSet `json:"changes,omitempty"`
@@ -223,6 +226,7 @@ func (d *OptionalDriverAgentDB) Connect(config connection.ConnectionConfig) erro
if err != nil {
return err
}
logger.Infof("%s 驱动代理路径:%s", driverDisplayName(d.driverType), executablePath)
client, err := newOptionalDriverAgentClient(d.driverType, executablePath)
if err != nil {
return err
@@ -260,7 +264,20 @@ func (d *OptionalDriverAgentDB) QueryContext(ctx context.Context, query string)
if err := ctx.Err(); err != nil {
return nil, nil, err
}
return d.Query(query)
client, err := d.requireClient()
if err != nil {
return nil, nil, err
}
var data []map[string]interface{}
var fields []string
if err := client.call(optionalAgentRequest{
Method: optionalAgentMethodQuery,
Query: query,
TimeoutMs: timeoutMsFromContext(ctx),
}, &data, &fields, nil); err != nil {
return nil, nil, err
}
return data, fields, nil
}
func (d *OptionalDriverAgentDB) Query(query string) ([]map[string]interface{}, []string, error) {
@@ -283,7 +300,19 @@ func (d *OptionalDriverAgentDB) ExecContext(ctx context.Context, query string) (
if err := ctx.Err(); err != nil {
return 0, err
}
return d.Exec(query)
client, err := d.requireClient()
if err != nil {
return 0, err
}
var affected int64
if err := client.call(optionalAgentRequest{
Method: optionalAgentMethodExec,
Query: query,
TimeoutMs: timeoutMsFromContext(ctx),
}, nil, nil, &affected); err != nil {
return 0, err
}
return affected, nil
}
func (d *OptionalDriverAgentDB) Exec(query string) (int64, error) {
@@ -443,3 +472,15 @@ func (d *OptionalDriverAgentDB) requireClient() (*optionalDriverAgentClient, err
}
return d.client, nil
}
func timeoutMsFromContext(ctx context.Context) int64 {
deadline, ok := ctx.Deadline()
if !ok {
return 0
}
remaining := time.Until(deadline).Milliseconds()
if remaining <= 0 {
return 1
}
return remaining
}

View File

@@ -0,0 +1,32 @@
package db
import (
"context"
"testing"
"time"
)
func TestTimeoutMsFromContext_NoDeadline(t *testing.T) {
if got := timeoutMsFromContext(context.Background()); got != 0 {
t.Fatalf("无 deadline 时应返回 0got=%d", got)
}
}
func TestTimeoutMsFromContext_WithDeadline(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got := timeoutMsFromContext(ctx)
if got <= 0 {
t.Fatalf("有 deadline 时应返回正值got=%d", got)
}
}
func TestTimeoutMsFromContext_ExpiredDeadline(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
if got := timeoutMsFromContext(ctx); got != 1 {
t.Fatalf("过期 deadline 应返回 1got=%d", got)
}
}