mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 17:39:46 +08:00
✨ feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路
- 统一 DuckDB 文件库与 Parquet 文件接入能力 - 补充 URI、文件选择、只读挂载与连接缓存键处理 - 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿 - refs #166
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "所有文件",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user