feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166
This commit is contained in:
杨国锋
2026-03-08 18:42:27 +08:00
parent e521d2125f
commit b85c7529ec
14 changed files with 35 additions and 342 deletions

View File

@@ -112,11 +112,6 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
// DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。
normalized.Host = dsn
normalized.Database = ""
if normalized.Type == "duckdb" {
normalized.DuckDBMode = normalizeDuckDBConnectionMode(normalized.DuckDBMode, dsn)
} else {
normalized.DuckDBMode = ""
}
normalized.Port = 0
normalized.User = ""
normalized.Password = ""
@@ -136,9 +131,6 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
normalized.HTTPTunnel = connection.HTTPTunnelConfig{}
}
if normalized.Type != "duckdb" {
normalized.DuckDBMode = ""
}
return normalized
}
@@ -153,21 +145,6 @@ func resolveFileDatabaseDSN(config connection.ConnectionConfig) string {
return dsn
}
func normalizeDuckDBConnectionMode(raw string, sourcePath string) string {
mode := strings.ToLower(strings.TrimSpace(raw))
if mode == "parquet" {
return "parquet"
}
if mode == "database" {
return "database"
}
lowerPath := strings.ToLower(strings.TrimSpace(sourcePath))
if strings.HasSuffix(lowerPath, ".parquet") || strings.HasSuffix(lowerPath, ".parq") {
return "parquet"
}
return "database"
}
// Helper: Generate a unique key for the connection config
func getCacheKey(config connection.ConnectionConfig) string {
normalized := normalizeCacheKeyConfig(config)
@@ -289,12 +266,7 @@ func formatConnSummary(config connection.ConnectionConfig) string {
if path == "" {
path = "(未配置)"
}
if normalizedType == "duckdb" {
mode := normalizeDuckDBConnectionMode(config.DuckDBMode, path)
b.WriteString(fmt.Sprintf("类型=%s 模式=%s 路径=%s 超时=%ds", config.Type, mode, path, timeoutSeconds))
} else {
b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds))
}
b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds))
} else {
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))

View File

@@ -61,34 +61,3 @@ func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) {
t.Fatalf("expected different cache key for different database targets")
}
}
func TestGetCacheKey_DuckDBModeAffectsKey(t *testing.T) {
databaseMode := connection.ConnectionConfig{
Type: "duckdb",
Host: `D:\data\songs.parquet`,
DuckDBMode: "database",
}
parquetMode := databaseMode
parquetMode.DuckDBMode = "parquet"
left := getCacheKey(databaseMode)
right := getCacheKey(parquetMode)
if left == right {
t.Fatalf("expected different cache key for duckdb file modes")
}
}
func TestGetCacheKey_DuckDBParquetModeInferenceConsistent(t *testing.T) {
inferred := connection.ConnectionConfig{
Type: "duckdb",
Host: `D:\data\songs.parquet`,
}
explicit := inferred
explicit.DuckDBMode = "parquet"
left := getCacheKey(inferred)
right := getCacheKey(explicit)
if left != right {
t.Fatalf("expected same cache key for inferred and explicit parquet mode, got %s vs %s", left, right)
}
}

View File

@@ -148,7 +148,7 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti
filters := []runtime.FileFilter{
{
DisplayName: "数据库文件",
Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb;*.parquet;*.parq",
Pattern: "*.db;*.sqlite;*.sqlite3;*.db3;*.duckdb;*.ddb",
},
{
DisplayName: "所有文件",
@@ -170,11 +170,11 @@ func (a *App) SelectDatabaseFile(currentPath string, driverType string) connecti
},
}
case "duckdb":
title = "选择 DuckDB / Parquet 文件"
title = "选择 DuckDB 数据文件"
filters = []runtime.FileFilter{
{
DisplayName: "DuckDB / Parquet 文件",
Pattern: "*.duckdb;*.ddb;*.db;*.parquet;*.parq",
DisplayName: "DuckDB 文件",
Pattern: "*.duckdb;*.ddb;*.db",
},
{
DisplayName: "所有文件",

View File

@@ -35,7 +35,6 @@ type ConnectionConfig struct {
Password string `json:"password"`
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
Database string `json:"database"`
DuckDBMode string `json:"duckdbMode,omitempty"`
UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch
SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable
SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng)

View File

@@ -6,10 +6,8 @@ import (
"context"
"database/sql"
"fmt"
"path/filepath"
"strings"
"time"
"unicode"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/utils"
@@ -18,9 +16,6 @@ import (
type DuckDB struct {
conn *sql.DB
pingTimeout time.Duration
mode string
sourcePath string
mountedView string
}
func (d *DuckDB) Connect(config connection.ConnectionConfig) error {
@@ -28,18 +23,11 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error {
return fmt.Errorf("DuckDB 驱动不可用:%s", reason)
}
sourcePath := strings.TrimSpace(config.Host)
if sourcePath == "" {
sourcePath = strings.TrimSpace(config.Database)
dsn := strings.TrimSpace(config.Host)
if dsn == "" {
dsn = strings.TrimSpace(config.Database)
}
mode := normalizeDuckDBConnectionMode(config.DuckDBMode, sourcePath)
dsn := sourcePath
if mode == "parquet" {
if strings.TrimSpace(sourcePath) == "" || sourcePath == ":memory:" {
return fmt.Errorf("Parquet 文件模式要求提供 .parquet 或 .parq 文件路径")
}
dsn = ":memory:"
} else if dsn == "" {
if dsn == "" {
dsn = ":memory:"
}
@@ -49,22 +37,12 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error {
}
d.conn = db
d.pingTimeout = getConnectTimeout(config)
d.mode = mode
d.sourcePath = sourcePath
d.mountedView = ""
if err := d.Ping(); err != nil {
_ = db.Close()
d.conn = nil
return fmt.Errorf("连接建立后验证失败:%w", err)
}
if mode == "parquet" {
if err := d.mountParquetView(sourcePath); err != nil {
_ = db.Close()
d.conn = nil
return fmt.Errorf("连接建立后挂载 Parquet 失败:%w", err)
}
}
return nil
}
@@ -421,26 +399,6 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
return tx.Commit()
}
func (d *DuckDB) mountParquetView(sourcePath string) error {
if d.conn == nil {
return fmt.Errorf("connection not open")
}
viewName := deriveDuckDBParquetViewName(sourcePath)
if viewName == "" {
viewName = "parquet_data"
}
query := fmt.Sprintf(
"CREATE OR REPLACE VIEW %s AS SELECT * FROM read_parquet('%s')",
quoteDuckDBQualifiedTable("main", viewName),
escapeDuckDBLiteral(sourcePath),
)
if _, err := d.conn.Exec(query); err != nil {
return err
}
d.mountedView = viewName
return nil
}
func normalizeDuckDBSchemaAndTable(dbName string, tableName string) (string, string) {
schema := strings.TrimSpace(dbName)
table := strings.TrimSpace(tableName)
@@ -506,49 +464,3 @@ func duckDBRowString(row map[string]interface{}, keys ...string) string {
func escapeDuckDBLiteral(raw string) string {
return strings.ReplaceAll(raw, "'", "''")
}
func normalizeDuckDBConnectionMode(raw string, sourcePath string) string {
mode := strings.ToLower(strings.TrimSpace(raw))
if mode == "parquet" {
return "parquet"
}
if mode == "database" {
return "database"
}
lowerPath := strings.ToLower(strings.TrimSpace(sourcePath))
if strings.HasSuffix(lowerPath, ".parquet") || strings.HasSuffix(lowerPath, ".parq") {
return "parquet"
}
return "database"
}
func deriveDuckDBParquetViewName(sourcePath string) string {
baseName := strings.TrimSpace(filepath.Base(strings.TrimSpace(sourcePath)))
if ext := filepath.Ext(baseName); ext != "" {
baseName = strings.TrimSuffix(baseName, ext)
}
if baseName == "" {
return "parquet_data"
}
var builder strings.Builder
for _, r := range baseName {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
builder.WriteRune(unicode.ToLower(r))
case r == '_':
builder.WriteRune(r)
default:
builder.WriteRune('_')
}
}
name := strings.Trim(builder.String(), "_")
if name == "" {
name = "parquet_data"
}
if unicode.IsDigit(rune(name[0])) {
name = "parquet_" + name
}
return name
}