feat(driver-proxy): 新增ClickHouse数据源并提供全局代理独立入口

- 新增 ClickHouse 可选驱动实现与 optional-driver-agent provider,补齐驱动注册与清单配置
- 补齐 ClickHouse 连接与 SQL 适配:连接默认端口/用户、LIMIT、标识符引用、只读编辑限制
- 新增全局代理后端能力与前端持久化配置,更新检查和驱动网络请求统一走代理客户端
This commit is contained in:
Syngnat
2026-02-27 16:39:13 +08:00
parent 9685102229
commit cb18bc3067
26 changed files with 1284 additions and 88 deletions

View File

@@ -208,15 +208,17 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro
}
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
key := getCacheKey(config)
effectiveConfig := applyGlobalProxyToConnection(config)
key := getCacheKey(effectiveConfig)
shortKey := key
if len(shortKey) > 12 {
shortKey = shortKey[:12]
}
if supported, reason := db.DriverRuntimeSupportStatus(config.Type); !supported {
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
if strings.TrimSpace(reason) == "" {
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(config.Type))
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
}
// Best-effort cleanup: if cached instance exists for this exact config, close it.
a.mu.Lock()
@@ -254,7 +256,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
a.mu.Unlock()
return entry.inst, nil
} else {
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(config), shortKey)
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
}
// Ping failed: remove cached instance (best effort)
@@ -268,24 +270,24 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
a.mu.Unlock()
}
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey)
dbInst, err := db.NewDatabase(config.Type)
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
dbInst, err := db.NewDatabase(effectiveConfig.Type)
if err != nil {
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", config.Type, shortKey)
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
return nil, err
}
connectConfig, proxyErr := resolveDialConfigWithProxy(config)
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
if proxyErr != nil {
wrapped := wrapConnectError(config, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
wrapped := wrapConnectError(effectiveConfig, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return nil, wrapped
}
if err := dbInst.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
wrapped := wrapConnectError(effectiveConfig, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return nil, wrapped
}
@@ -301,6 +303,6 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
a.mu.Unlock()
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey)
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
return dbInst, nil
}

View File

@@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
}
switch strings.ToLower(strings.TrimSpace(config.Type)) {
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine", "clickhouse":
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
runConfig.Database = name
case "dameng":

View File

@@ -194,6 +194,8 @@ func defaultPortByType(driverType string) int {
return 1433
case "mongodb":
return 27017
case "clickhouse":
return 9000
case "highgo":
return 5866
default:

View File

@@ -0,0 +1,191 @@
package app
import (
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
)
type globalProxySnapshot struct {
Enabled bool `json:"enabled"`
Proxy connection.ProxyConfig `json:"proxy"`
}
var globalProxyRuntime = struct {
mu sync.RWMutex
enabled bool
proxy connection.ProxyConfig
}{}
func currentGlobalProxyConfig() globalProxySnapshot {
globalProxyRuntime.mu.RLock()
defer globalProxyRuntime.mu.RUnlock()
if !globalProxyRuntime.enabled {
return globalProxySnapshot{
Enabled: false,
Proxy: connection.ProxyConfig{},
}
}
return globalProxySnapshot{
Enabled: true,
Proxy: globalProxyRuntime.proxy,
}
}
func setGlobalProxyConfig(enabled bool, proxyConfig connection.ProxyConfig) (globalProxySnapshot, error) {
if !enabled {
globalProxyRuntime.mu.Lock()
globalProxyRuntime.enabled = false
globalProxyRuntime.proxy = connection.ProxyConfig{}
globalProxyRuntime.mu.Unlock()
return currentGlobalProxyConfig(), nil
}
normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig)
if err != nil {
return globalProxySnapshot{}, err
}
globalProxyRuntime.mu.Lock()
globalProxyRuntime.enabled = true
globalProxyRuntime.proxy = normalizedProxy
globalProxyRuntime.mu.Unlock()
return currentGlobalProxyConfig(), nil
}
func (a *App) ConfigureGlobalProxy(enabled bool, proxyConfig connection.ProxyConfig) connection.QueryResult {
snapshot, err := setGlobalProxyConfig(enabled, proxyConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if snapshot.Enabled {
authState := ""
if strings.TrimSpace(snapshot.Proxy.User) != "" {
authState = "(认证:已配置)"
}
logger.Infof(
"全局代理已启用:%s://%s:%d%s",
strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)),
strings.TrimSpace(snapshot.Proxy.Host),
snapshot.Proxy.Port,
authState,
)
} else {
logger.Infof("全局代理已关闭")
}
return connection.QueryResult{
Success: true,
Message: "全局代理配置已生效",
Data: snapshot,
}
}
func (a *App) GetGlobalProxyConfig() connection.QueryResult {
return connection.QueryResult{
Success: true,
Message: "OK",
Data: currentGlobalProxyConfig(),
}
}
func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig {
effective := config
if effective.UseProxy {
return effective
}
if isFileDatabaseType(effective.Type) {
effective.Proxy = connection.ProxyConfig{}
return effective
}
snapshot := currentGlobalProxyConfig()
if !snapshot.Enabled {
effective.Proxy = connection.ProxyConfig{}
return effective
}
effective.UseProxy = true
effective.Proxy = snapshot.Proxy
return effective
}
func isFileDatabaseType(driverType string) bool {
switch strings.ToLower(strings.TrimSpace(driverType)) {
case "sqlite", "duckdb":
return true
default:
return false
}
}
func newHTTPClientWithGlobalProxy(timeout time.Duration) *http.Client {
client := &http.Client{
Timeout: timeout,
}
if transport := buildHTTPTransportWithGlobalProxy(); transport != nil {
client.Transport = transport
}
return client
}
func buildHTTPTransportWithGlobalProxy() *http.Transport {
baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok || baseTransport == nil {
return nil
}
transport := baseTransport.Clone()
snapshot := currentGlobalProxyConfig()
if !snapshot.Enabled {
transport.Proxy = http.ProxyFromEnvironment
return transport
}
proxyURL, err := buildProxyURLFromConfig(snapshot.Proxy)
if err != nil {
logger.Warnf("全局代理配置无效,回退系统代理:%v", err)
transport.Proxy = http.ProxyFromEnvironment
return transport
}
transport.Proxy = http.ProxyURL(proxyURL)
return transport
}
func buildProxyURLFromConfig(proxyConfig connection.ProxyConfig) (*url.URL, error) {
normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
proxyType := strings.ToLower(strings.TrimSpace(normalizedProxy.Type))
if proxyType != "http" && proxyType != "socks5" {
return nil, fmt.Errorf("不支持的代理类型:%s", normalizedProxy.Type)
}
if strings.TrimSpace(normalizedProxy.Host) == "" {
return nil, fmt.Errorf("代理地址不能为空")
}
if normalizedProxy.Port <= 0 || normalizedProxy.Port > 65535 {
return nil, fmt.Errorf("代理端口无效:%d", normalizedProxy.Port)
}
proxyURL := &url.URL{
Scheme: proxyType,
Host: net.JoinHostPort(strings.TrimSpace(normalizedProxy.Host), strconv.Itoa(normalizedProxy.Port)),
}
if strings.TrimSpace(normalizedProxy.User) != "" {
proxyURL.User = url.UserPassword(strings.TrimSpace(normalizedProxy.User), normalizedProxy.Password)
}
return proxyURL, nil
}

View File

@@ -88,6 +88,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
} else if dbType == "tdengine" {
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
} else if dbType == "clickhouse" {
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
} else if dbType == "mariadb" || dbType == "diros" {
// MariaDB uses same syntax as MySQL
} else if dbType == "sphinx" {
@@ -162,7 +164,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":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng", "clickhouse":
if strings.TrimSpace(dbName) != "" {
runConfig.Database = strings.TrimSpace(dbName)
}
@@ -216,7 +218,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
sql string
)
switch dbType {
case "mysql", "mariadb", "diros", "tdengine":
case "mysql", "mariadb", "diros", "tdengine", "clickhouse":
runConfig = config
runConfig.Database = ""
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
@@ -255,7 +257,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "clickhouse":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)}
}
@@ -269,7 +271,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
var sql string
switch dbType {
case "mysql", "mariadb", "diros", "sphinx":
case "mysql", "mariadb", "diros", "sphinx", "clickhouse":
newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName)
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable)
case "sqlserver":
@@ -301,7 +303,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine", "clickhouse":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)}
}
@@ -663,7 +665,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "clickhouse":
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)}
}
@@ -752,7 +754,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN
var sql string
switch dbType {
case "mysql", "mariadb", "diros", "sphinx":
case "mysql", "mariadb", "diros", "sphinx", "clickhouse":
newQualified := quoteTableIdentByType(dbType, schemaName, newName)
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified)
case "postgres", "kingbase", "highgo", "vastbase":

View File

@@ -222,7 +222,8 @@ const builtinDriverManifestJSON = `{
"highgo": { "engine": "go", "version": "0.0.0-local", "checksumPolicy": "off", "downloadUrl": "builtin://activate/highgo" },
"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" }
"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" }
}
}`
@@ -261,37 +262,39 @@ var pinnedDriverPackageMap = map[string]pinnedDriverPackage{
}
var latestDriverVersionMap = map[string]string{
"mysql": "1.9.3",
"mariadb": "1.9.3",
"diros": "1.9.3",
"sphinx": "1.9.3",
"sqlserver": "1.9.6",
"sqlite": "1.46.1",
"duckdb": "2.5.5",
"dameng": "1.8.22",
"kingbase": "0.0.0-20201021123113-29bd62a876c3",
"highgo": "0.0.0-local",
"vastbase": "1.11.2",
"mongodb": "2.5.0",
"tdengine": "3.7.8",
"oracle": "2.9.0",
"postgres": "1.11.2",
"redis": "9.17.3",
"mysql": "1.9.3",
"mariadb": "1.9.3",
"diros": "1.9.3",
"sphinx": "1.9.3",
"sqlserver": "1.9.6",
"sqlite": "1.46.1",
"duckdb": "2.5.5",
"dameng": "1.8.22",
"kingbase": "0.0.0-20201021123113-29bd62a876c3",
"highgo": "0.0.0-local",
"vastbase": "1.11.2",
"mongodb": "2.5.0",
"tdengine": "3.7.8",
"clickhouse": "2.43.0",
"oracle": "2.9.0",
"postgres": "1.11.2",
"redis": "9.17.3",
}
var driverGoModulePathMap = map[string]string{
"mariadb": "github.com/go-sql-driver/mysql",
"diros": "github.com/go-sql-driver/mysql",
"sphinx": "github.com/go-sql-driver/mysql",
"sqlserver": "github.com/microsoft/go-mssqldb",
"sqlite": "modernc.org/sqlite",
"duckdb": "github.com/duckdb/duckdb-go/v2",
"dameng": "gitee.com/chunanyong/dm",
"kingbase": "gitea.com/kingbase/gokb",
"highgo": "github.com/highgo/pq-sm3",
"vastbase": "github.com/lib/pq",
"mongodb": "go.mongodb.org/mongo-driver/v2",
"tdengine": "github.com/taosdata/driver-go/v3",
"mariadb": "github.com/go-sql-driver/mysql",
"diros": "github.com/go-sql-driver/mysql",
"sphinx": "github.com/go-sql-driver/mysql",
"sqlserver": "github.com/microsoft/go-mssqldb",
"sqlite": "modernc.org/sqlite",
"duckdb": "github.com/duckdb/duckdb-go/v2",
"dameng": "gitee.com/chunanyong/dm",
"kingbase": "gitea.com/kingbase/gokb",
"highgo": "github.com/highgo/pq-sm3",
"vastbase": "github.com/lib/pq",
"mongodb": "go.mongodb.org/mongo-driver/v2",
"tdengine": "github.com/taosdata/driver-go/v3",
"clickhouse": "github.com/ClickHouse/clickhouse-go/v2",
}
var fallbackRecentDriverVersionsMap = map[string][]goModuleVersionMeta{
@@ -870,7 +873,7 @@ func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeI
return probed
}
client := &http.Client{Timeout: driverNetworkProbeTimeout}
client := newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout)
start := time.Now()
req, err := http.NewRequest(http.MethodHead, urlText, nil)
if err != nil {
@@ -1046,6 +1049,7 @@ func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) [
buildOptionalGoDriverDefinition("vastbase", "Vastbase", packages),
buildOptionalGoDriverDefinition("mongodb", "MongoDB", packages),
buildOptionalGoDriverDefinition("tdengine", "TDengine", packages),
buildOptionalGoDriverDefinition("clickhouse", "ClickHouse", packages),
}
}
@@ -1548,7 +1552,7 @@ func fetchGoModuleVersionMetas(modulePath string) ([]goModuleVersionMeta, error)
}
endpoint := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escapeGoModulePathForProxy(trimmed))
client := &http.Client{Timeout: driverModuleLatestProbeTimeout}
client := newHTTPClientWithGlobalProxy(driverModuleLatestProbeTimeout)
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
@@ -1689,7 +1693,7 @@ func loadDriverReleaseListCached() ([]githubRelease, error) {
func fetchDriverReleaseList() ([]githubRelease, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=30", updateRepo)
client := &http.Client{Timeout: driverReleaseListProbeTimeout}
client := newHTTPClientWithGlobalProxy(driverReleaseListProbeTimeout)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
@@ -2019,7 +2023,7 @@ func loadManifestContent(resolvedURL string) ([]byte, error) {
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
switch scheme {
case "http", "https":
client := &http.Client{Timeout: 12 * time.Second}
client := newHTTPClientWithGlobalProxy(12 * time.Second)
req, reqErr := http.NewRequest(http.MethodGet, parsed.String(), nil)
if reqErr != nil {
return nil, reqErr
@@ -2605,6 +2609,8 @@ func optionalDriverBuildTag(driverType string) (string, error) {
return "gonavi_mongodb_driver", nil
case "tdengine":
return "gonavi_tdengine_driver", nil
case "clickhouse":
return "gonavi_clickhouse_driver", nil
default:
return "", fmt.Errorf("未配置驱动构建标签:%s", driverType)
}
@@ -3026,7 +3032,7 @@ func fetchDriverBundleAssetSizeIndex(release *githubRelease) (map[string]int64,
return nil, fmt.Errorf("未找到驱动总包索引资产")
}
client := &http.Client{Timeout: driverReleaseAssetSizeProbeTimeout}
client := newHTTPClientWithGlobalProxy(driverReleaseAssetSizeProbeTimeout)
req, err := http.NewRequest(http.MethodGet, indexURL, nil)
if err != nil {
return nil, err
@@ -3074,7 +3080,7 @@ func fetchDriverReleaseByURL(apiURL string) (*githubRelease, error) {
return nil, fmt.Errorf("API 地址为空")
}
client := &http.Client{Timeout: driverReleaseAssetSizeProbeTimeout}
client := newHTTPClientWithGlobalProxy(driverReleaseAssetSizeProbeTimeout)
req, err := http.NewRequest(http.MethodGet, urlText, nil)
if err != nil {
return nil, err

View File

@@ -701,7 +701,7 @@ func quoteIdentByType(dbType string, ident string) string {
}
switch dbType {
case "mysql", "mariadb", "diros", "sphinx", "tdengine":
case "mysql", "mariadb", "diros", "sphinx", "tdengine", "clickhouse":
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
case "sqlserver":
escaped := strings.ReplaceAll(ident, "]", "]]")
@@ -950,6 +950,15 @@ func buildListViewQueries(config connection.ConnectionConfig, dbName string) []s
return []string{
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`,
}
case "clickhouse":
if strings.TrimSpace(dbName) == "" {
return []string{
`SELECT database AS schema_name, name AS object_name FROM system.tables WHERE engine LIKE '%View%' ORDER BY database, name`,
}
}
return []string{
fmt.Sprintf(`SELECT database AS schema_name, name AS object_name FROM system.tables WHERE engine LIKE '%%View%%' AND database='%s' ORDER BY name`, escapedDbName),
}
default:
if strings.TrimSpace(dbName) == "" {
return []string{
@@ -1070,6 +1079,18 @@ WHERE s.name = '%s' AND v.name = '%s'`,
fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema),
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
}
case "clickhouse":
if safeSchema == "" {
safeSchema = strings.TrimSpace(dbName)
}
if safeSchema != "" {
return []string{
fmt.Sprintf("SHOW CREATE TABLE %s.%s", quoteIdentByType("clickhouse", safeSchema), quoteIdentByType("clickhouse", safeView)),
}
}
return []string{
fmt.Sprintf("SHOW CREATE TABLE %s", quoteIdentByType("clickhouse", safeView)),
}
default:
if safeSchema != "" {
return []string{

View File

@@ -374,7 +374,7 @@ func getCurrentAuthor() string {
}
func fetchLatestRelease() (*githubRelease, error) {
client := &http.Client{Timeout: 15 * time.Second}
client := newHTTPClientWithGlobalProxy(15 * time.Second)
req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil)
if err != nil {
return nil, err
@@ -451,7 +451,7 @@ func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
return nil, errors.New("Release 未提供 SHA256SUMS")
}
client := &http.Client{Timeout: 15 * time.Second}
client := newHTTPClientWithGlobalProxy(15 * time.Second)
req, err := http.NewRequest(http.MethodGet, checksumURL, nil)
if err != nil {
return nil, err
@@ -522,7 +522,7 @@ func (w *downloadProgressWriter) Write(p []byte) (int, error) {
}
func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) {
client := &http.Client{Timeout: 10 * time.Minute}
client := newHTTPClientWithGlobalProxy(10 * time.Minute)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err

View File

@@ -0,0 +1,603 @@
//go:build gonavi_full_drivers || gonavi_clickhouse_driver
package db
import (
"context"
"database/sql"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
_ "github.com/ClickHouse/clickhouse-go/v2"
)
const (
defaultClickHousePort = 9000
defaultClickHouseUser = "default"
defaultClickHouseDatabase = "default"
)
type ClickHouseDB struct {
conn *sql.DB
pingTimeout time.Duration
forwarder *ssh.LocalForwarder
database string
}
func normalizeClickHouseConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
normalized := applyClickHouseURI(config)
if strings.TrimSpace(normalized.Host) == "" {
normalized.Host = "localhost"
}
if normalized.Port <= 0 {
normalized.Port = defaultClickHousePort
}
if strings.TrimSpace(normalized.User) == "" {
normalized.User = defaultClickHouseUser
}
if strings.TrimSpace(normalized.Database) == "" {
normalized.Database = defaultClickHouseDatabase
}
return normalized
}
func applyClickHouseURI(config connection.ConnectionConfig) connection.ConnectionConfig {
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return config
}
lowerURI := strings.ToLower(uriText)
if !strings.HasPrefix(lowerURI, "clickhouse://") {
return config
}
parsed, err := url.Parse(uriText)
if err != nil {
return config
}
if parsed.User != nil {
if strings.TrimSpace(config.User) == "" {
config.User = parsed.User.Username()
}
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
config.Password = pass
}
}
if dbName := strings.TrimPrefix(strings.TrimSpace(parsed.Path), "/"); dbName != "" && strings.TrimSpace(config.Database) == "" {
config.Database = dbName
}
if strings.TrimSpace(config.Database) == "" {
if dbName := strings.TrimSpace(parsed.Query().Get("database")); dbName != "" {
config.Database = dbName
}
}
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultClickHousePort
}
if strings.TrimSpace(config.Host) == "" {
host, port, ok := parseHostPortWithDefault(parsed.Host, defaultPort)
if ok {
config.Host = host
config.Port = port
}
}
if config.Port <= 0 {
config.Port = defaultPort
}
return config
}
func (c *ClickHouseDB) getDSN(config connection.ConnectionConfig) string {
u := &url.URL{
Scheme: "clickhouse",
Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
Path: "/" + strings.TrimPrefix(strings.TrimSpace(config.Database), "/"),
}
if strings.TrimSpace(config.Password) != "" {
u.User = url.UserPassword(strings.TrimSpace(config.User), config.Password)
} else {
u.User = url.User(strings.TrimSpace(config.User))
}
timeoutSeconds := getConnectTimeoutSeconds(config)
query := u.Query()
query.Set("dial_timeout", fmt.Sprintf("%ds", timeoutSeconds))
query.Set("read_timeout", fmt.Sprintf("%ds", timeoutSeconds))
query.Set("write_timeout", fmt.Sprintf("%ds", timeoutSeconds))
u.RawQuery = query.Encode()
return u.String()
}
func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported {
if strings.TrimSpace(reason) == "" {
reason = "ClickHouse 纯 Go 驱动未启用,请先在驱动管理中安装启用"
}
return fmt.Errorf("%s", reason)
}
if c.forwarder != nil {
_ = c.forwarder.Close()
c.forwarder = nil
}
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
runConfig := normalizeClickHouseConfig(config)
c.pingTimeout = getConnectTimeout(runConfig)
c.database = runConfig.Database
if runConfig.UseSSH {
logger.Infof("ClickHouse 使用 SSH 连接:地址=%s:%d 用户=%s", runConfig.Host, runConfig.Port, runConfig.User)
forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, runConfig.Host, runConfig.Port)
if err != nil {
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
}
c.forwarder = forwarder
host, portText, err := net.SplitHostPort(forwarder.LocalAddr)
if err != nil {
return fmt.Errorf("解析本地转发地址失败:%w", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
return fmt.Errorf("解析本地端口失败:%w", err)
}
runConfig.Host = host
runConfig.Port = port
runConfig.UseSSH = false
logger.Infof("ClickHouse 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
}
dbConn, err := sql.Open("clickhouse", c.getDSN(runConfig))
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
c.conn = dbConn
if err := c.Ping(); err != nil {
_ = c.Close()
return fmt.Errorf("连接建立后验证失败:%w", err)
}
return nil
}
func (c *ClickHouseDB) Close() error {
if c.forwarder != nil {
if err := c.forwarder.Close(); err != nil {
logger.Warnf("关闭 ClickHouse SSH 端口转发失败:%v", err)
}
c.forwarder = nil
}
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func (c *ClickHouseDB) Ping() error {
if c.conn == nil {
return fmt.Errorf("connection not open")
}
timeout := c.pingTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := utils.ContextWithTimeout(timeout)
defer cancel()
return c.conn.PingContext(ctx)
}
func (c *ClickHouseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
}
rows, err := c.conn.QueryContext(ctx, query)
if err != nil {
return nil, nil, err
}
defer rows.Close()
return scanRows(rows)
}
func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
}
rows, err := c.conn.Query(query)
if err != nil {
return nil, nil, err
}
defer rows.Close()
return scanRows(rows)
}
func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("connection not open")
}
res, err := c.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (c *ClickHouseDB) Exec(query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("connection not open")
}
res, err := c.conn.Exec(query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (c *ClickHouseDB) GetDatabases() ([]string, error) {
data, _, err := c.Query("SELECT name FROM system.databases ORDER BY name")
if err != nil {
return nil, err
}
result := make([]string, 0, len(data))
for _, row := range data {
if val, ok := getClickHouseValueFromRow(row, "name", "database"); ok {
result = append(result, fmt.Sprintf("%v", val))
continue
}
for _, value := range row {
result = append(result, fmt.Sprintf("%v", value))
break
}
}
return result, nil
}
func (c *ClickHouseDB) GetTables(dbName string) ([]string, error) {
targetDB := strings.TrimSpace(dbName)
if targetDB == "" {
targetDB = strings.TrimSpace(c.database)
}
var query string
if targetDB != "" {
query = fmt.Sprintf(
"SELECT name FROM system.tables WHERE database = '%s' ORDER BY name",
escapeClickHouseSQLLiteral(targetDB),
)
} else {
query = "SELECT database, name FROM system.tables ORDER BY database, name"
}
data, _, err := c.Query(query)
if err != nil {
return nil, err
}
result := make([]string, 0, len(data))
for _, row := range data {
if targetDB != "" {
if val, ok := getClickHouseValueFromRow(row, "name", "table", "table_name"); ok {
result = append(result, fmt.Sprintf("%v", val))
continue
}
} else {
databaseValue, hasDB := getClickHouseValueFromRow(row, "database", "schema_name")
tableValue, hasTable := getClickHouseValueFromRow(row, "name", "table", "table_name")
if hasDB && hasTable {
result = append(result, fmt.Sprintf("%v.%v", databaseValue, tableValue))
continue
}
}
for _, value := range row {
result = append(result, fmt.Sprintf("%v", value))
break
}
}
return result, nil
}
func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, error) {
database, table, err := c.resolveDatabaseAndTable(dbName, tableName)
if err != nil {
return "", err
}
query := fmt.Sprintf("SHOW CREATE TABLE %s.%s", quoteClickHouseIdentifier(database), quoteClickHouseIdentifier(table))
data, _, err := c.Query(query)
if err != nil {
return "", err
}
if len(data) == 0 {
return "", fmt.Errorf("create statement not found")
}
row := data[0]
if val, ok := getClickHouseValueFromRow(row, "statement", "create_statement", "sql", "query"); ok {
text := strings.TrimSpace(fmt.Sprintf("%v", val))
if text != "" {
return text, nil
}
}
longest := ""
for _, value := range row {
text := strings.TrimSpace(fmt.Sprintf("%v", value))
if text == "" {
continue
}
if strings.Contains(strings.ToUpper(text), "CREATE ") && len(text) > len(longest) {
longest = text
}
}
if longest != "" {
return longest, nil
}
return "", fmt.Errorf("create statement not found")
}
func (c *ClickHouseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
database, table, err := c.resolveDatabaseAndTable(dbName, tableName)
if err != nil {
return nil, err
}
query := fmt.Sprintf(`
SELECT
name,
type,
default_kind,
default_expression,
is_in_primary_key,
is_in_sorting_key,
comment
FROM system.columns
WHERE database = '%s' AND table = '%s'
ORDER BY position`,
escapeClickHouseSQLLiteral(database),
escapeClickHouseSQLLiteral(table),
)
data, _, err := c.Query(query)
if err != nil {
return nil, err
}
columns := make([]connection.ColumnDefinition, 0, len(data))
for _, row := range data {
nameValue, _ := getClickHouseValueFromRow(row, "name", "column_name")
typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type")
defaultKind, _ := getClickHouseValueFromRow(row, "default_kind")
defaultExpr, hasDefault := getClickHouseValueFromRow(row, "default_expression", "column_default")
commentValue, _ := getClickHouseValueFromRow(row, "comment")
inPrimary, _ := getClickHouseValueFromRow(row, "is_in_primary_key")
inSorting, _ := getClickHouseValueFromRow(row, "is_in_sorting_key")
colType := strings.TrimSpace(fmt.Sprintf("%v", typeValue))
nullable := "NO"
if strings.HasPrefix(strings.ToLower(colType), "nullable(") {
nullable = "YES"
}
key := ""
if isClickHouseTruthy(inPrimary) {
key = "PRI"
} else if isClickHouseTruthy(inSorting) {
key = "MUL"
}
extra := ""
kindText := strings.ToUpper(strings.TrimSpace(fmt.Sprintf("%v", defaultKind)))
if kindText != "" && kindText != "DEFAULT" {
extra = kindText
}
col := connection.ColumnDefinition{
Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)),
Type: colType,
Nullable: nullable,
Key: key,
Extra: extra,
Comment: strings.TrimSpace(fmt.Sprintf("%v", commentValue)),
}
if hasDefault && defaultExpr != nil {
text := strings.TrimSpace(fmt.Sprintf("%v", defaultExpr))
if text != "" {
col.Default = &text
}
}
columns = append(columns, col)
}
return columns, nil
}
func (c *ClickHouseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
targetDB := strings.TrimSpace(dbName)
if targetDB == "" {
targetDB = strings.TrimSpace(c.database)
}
var query string
if targetDB != "" {
query = fmt.Sprintf(`
SELECT
database,
table,
name,
type
FROM system.columns
WHERE database = '%s'
ORDER BY table, position`,
escapeClickHouseSQLLiteral(targetDB),
)
} else {
query = `
SELECT
database,
table,
name,
type
FROM system.columns
WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
ORDER BY database, table, position`
}
data, _, err := c.Query(query)
if err != nil {
return nil, err
}
result := make([]connection.ColumnDefinitionWithTable, 0, len(data))
for _, row := range data {
databaseValue, _ := getClickHouseValueFromRow(row, "database")
tableValue, hasTable := getClickHouseValueFromRow(row, "table", "table_name")
nameValue, hasName := getClickHouseValueFromRow(row, "name", "column_name")
typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type")
if !hasTable || !hasName {
continue
}
tableName := strings.TrimSpace(fmt.Sprintf("%v", tableValue))
if targetDB == "" {
dbText := strings.TrimSpace(fmt.Sprintf("%v", databaseValue))
if dbText != "" {
tableName = dbText + "." + tableName
}
}
result = append(result, connection.ColumnDefinitionWithTable{
TableName: tableName,
Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)),
Type: strings.TrimSpace(fmt.Sprintf("%v", typeValue)),
})
}
return result, nil
}
func (c *ClickHouseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
}
func (c *ClickHouseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
}
func (c *ClickHouseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
}
func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string, string, error) {
rawTable := strings.TrimSpace(tableName)
if rawTable == "" {
return "", "", fmt.Errorf("table name required")
}
resolvedDB := strings.TrimSpace(dbName)
resolvedTable := rawTable
if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 {
if dbPart := normalizeClickHouseIdentifierPart(parts[0]); dbPart != "" {
resolvedDB = dbPart
}
resolvedTable = normalizeClickHouseIdentifierPart(parts[1])
} else {
resolvedTable = normalizeClickHouseIdentifierPart(rawTable)
}
if resolvedDB == "" {
resolvedDB = strings.TrimSpace(c.database)
}
if resolvedDB == "" {
resolvedDB = defaultClickHouseDatabase
}
if resolvedTable == "" {
return "", "", fmt.Errorf("table name required")
}
return resolvedDB, resolvedTable, nil
}
func normalizeClickHouseIdentifierPart(raw string) string {
text := strings.TrimSpace(raw)
if len(text) >= 2 {
first := text[0]
last := text[len(text)-1]
if (first == '`' && last == '`') || (first == '"' && last == '"') {
text = text[1 : len(text)-1]
}
}
return strings.TrimSpace(text)
}
func quoteClickHouseIdentifier(raw string) string {
return "`" + strings.ReplaceAll(strings.TrimSpace(raw), "`", "``") + "`"
}
func escapeClickHouseSQLLiteral(raw string) string {
return strings.ReplaceAll(strings.TrimSpace(raw), "'", "''")
}
func getClickHouseValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) {
if len(row) == 0 {
return nil, false
}
for _, key := range keys {
if value, ok := row[key]; ok {
return value, true
}
}
for existingKey, value := range row {
for _, key := range keys {
if strings.EqualFold(existingKey, key) {
return value, true
}
}
}
return nil, false
}
func isClickHouseTruthy(value interface{}) bool {
switch val := value.(type) {
case bool:
return val
case int:
return val != 0
case int8:
return val != 0
case int16:
return val != 0
case int32:
return val != 0
case int64:
return val != 0
case uint:
return val != 0
case uint8:
return val != 0
case uint16:
return val != 0
case uint32:
return val != 0
case uint64:
return val != 0
case string:
normalized := strings.ToLower(strings.TrimSpace(val))
return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "y"
default:
normalized := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", value)))
return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "y"
}
}

View File

@@ -15,4 +15,5 @@ func registerOptionalDatabaseFactories() {
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
}

View File

@@ -15,4 +15,5 @@ func registerOptionalDatabaseFactories() {
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
}

View File

@@ -18,18 +18,19 @@ var coreBuiltinDrivers = map[string]struct{}{
// optionalGoDrivers 表示需要用户“安装启用”后才能使用的纯 Go 驱动。
// 注意这是一种运行时门控installed.json 标记),并不减少主二进制体积。
var optionalGoDrivers = map[string]struct{}{
"mariadb": {},
"diros": {},
"sphinx": {},
"sqlserver": {},
"sqlite": {},
"duckdb": {},
"dameng": {},
"kingbase": {},
"highgo": {},
"vastbase": {},
"mongodb": {},
"tdengine": {},
"mariadb": {},
"diros": {},
"sphinx": {},
"sqlserver": {},
"sqlite": {},
"duckdb": {},
"dameng": {},
"kingbase": {},
"highgo": {},
"vastbase": {},
"mongodb": {},
"tdengine": {},
"clickhouse": {},
}
var (
@@ -83,6 +84,8 @@ func driverDisplayName(driverType string) string {
return "MongoDB"
case "tdengine":
return "TDengine"
case "clickhouse":
return "ClickHouse"
default:
return strings.ToUpper(strings.TrimSpace(driverType))
}

View File

@@ -114,3 +114,30 @@ func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) {
t.Fatalf("tdengine dsn 格式不正确:%s", dsn)
}
}
func TestClickHouseDSN_EscapesPasswordAndSetsTimeout(t *testing.T) {
c := &ClickHouseDB{}
cfg := normalizeClickHouseConfig(connection.ConnectionConfig{
Type: "clickhouse",
Host: "127.0.0.1",
Port: 9000,
User: "default",
Password: "p@ss:wo/rd",
Database: "analytics",
Timeout: 15,
})
dsn := c.getDSN(cfg)
if strings.Contains(dsn, cfg.Password) {
t.Fatalf("dsn 包含原始密码:%s", dsn)
}
if !strings.Contains(dsn, "p%40ss%3Awo%2Frd") {
t.Fatalf("dsn 未正确转义密码:%s", dsn)
}
if !strings.Contains(dsn, "dial_timeout=15s") {
t.Fatalf("dsn 缺少 dial_timeout 参数:%s", dsn)
}
if !strings.Contains(dsn, "/analytics") {
t.Fatalf("dsn 缺少数据库路径:%s", dsn)
}
}