mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-15 12:27:38 +08:00
- 支持 ClickHouse 手动 HTTP/Native 协议优先级,避免 URI scheme 覆盖用户选择 - Auto 模式识别 Native/HTTP 协议误配错误并自动尝试备用协议 - 净化连接失败中的二进制乱码,补充测试连接参数校验和排查日志 - 前端表单增加 ClickHouse 协议选择并同步类型、缓存 key 与持久化兼容 Refs #425
468 lines
13 KiB
Go
468 lines
13 KiB
Go
//go:build gonavi_full_drivers || gonavi_clickhouse_driver
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"GoNavi-Wails/internal/connection"
|
|
|
|
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
|
|
)
|
|
|
|
const fakeClickHouseDriverName = "gonavi-fake-clickhouse"
|
|
|
|
var (
|
|
registerFakeClickHouseDriverOnce sync.Once
|
|
fakeClickHouseStateMu sync.Mutex
|
|
fakeClickHouseState = struct {
|
|
pingErr error
|
|
queryErr error
|
|
queryResults map[string]fakeClickHouseQueryResult
|
|
lastQuery string
|
|
queries []string
|
|
}{
|
|
lastQuery: "",
|
|
queryResults: map[string]fakeClickHouseQueryResult{},
|
|
queries: nil,
|
|
}
|
|
)
|
|
|
|
type fakeClickHouseQueryResult struct {
|
|
columns []string
|
|
rows [][]driver.Value
|
|
err error
|
|
}
|
|
|
|
func TestClickHousePingValidatesQueryPath(t *testing.T) {
|
|
registerFakeClickHouseDriverOnce.Do(func() {
|
|
sql.Register(fakeClickHouseDriverName, fakeClickHouseDriver{})
|
|
})
|
|
|
|
db, err := sql.Open(fakeClickHouseDriverName, "")
|
|
if err != nil {
|
|
t.Fatalf("open fake clickhouse db failed: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
fakeClickHouseStateMu.Lock()
|
|
fakeClickHouseState.pingErr = nil
|
|
fakeClickHouseState.queryErr = errors.New("query path failed")
|
|
fakeClickHouseState.queryResults = map[string]fakeClickHouseQueryResult{}
|
|
fakeClickHouseState.lastQuery = ""
|
|
fakeClickHouseState.queries = nil
|
|
fakeClickHouseStateMu.Unlock()
|
|
|
|
client := &ClickHouseDB{
|
|
conn: db,
|
|
pingTimeout: time.Second,
|
|
}
|
|
err = client.Ping()
|
|
if err == nil {
|
|
t.Fatal("expected Ping to fail when query validation fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "query path failed") {
|
|
t.Fatalf("expected query validation error, got %v", err)
|
|
}
|
|
|
|
fakeClickHouseStateMu.Lock()
|
|
lastQuery := fakeClickHouseState.lastQuery
|
|
fakeClickHouseStateMu.Unlock()
|
|
if lastQuery != "SELECT currentDatabase()" {
|
|
t.Fatalf("expected query validation SQL to run, got %q", lastQuery)
|
|
}
|
|
}
|
|
|
|
func TestClickHouseGetDatabasesFallsBackToCurrentDatabase(t *testing.T) {
|
|
registerFakeClickHouseDriverOnce.Do(func() {
|
|
sql.Register(fakeClickHouseDriverName, fakeClickHouseDriver{})
|
|
})
|
|
|
|
db, err := sql.Open(fakeClickHouseDriverName, "")
|
|
if err != nil {
|
|
t.Fatalf("open fake clickhouse db failed: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const listSQL = "SELECT name FROM system.databases ORDER BY name"
|
|
const fallbackSQL = "SELECT currentDatabase() AS name"
|
|
|
|
fakeClickHouseStateMu.Lock()
|
|
fakeClickHouseState.pingErr = nil
|
|
fakeClickHouseState.queryErr = nil
|
|
fakeClickHouseState.queryResults = map[string]fakeClickHouseQueryResult{
|
|
listSQL: {
|
|
err: errors.New("access denied to system.databases"),
|
|
},
|
|
fallbackSQL: {
|
|
columns: []string{"name"},
|
|
rows: [][]driver.Value{
|
|
{"analytics"},
|
|
},
|
|
},
|
|
}
|
|
fakeClickHouseState.lastQuery = ""
|
|
fakeClickHouseState.queries = nil
|
|
fakeClickHouseStateMu.Unlock()
|
|
|
|
client := &ClickHouseDB{conn: db}
|
|
databases, err := client.GetDatabases()
|
|
if err != nil {
|
|
t.Fatalf("expected GetDatabases to fallback, got err=%v", err)
|
|
}
|
|
if len(databases) != 1 || databases[0] != "analytics" {
|
|
t.Fatalf("expected fallback database list, got %v", databases)
|
|
}
|
|
|
|
fakeClickHouseStateMu.Lock()
|
|
queries := append([]string(nil), fakeClickHouseState.queries...)
|
|
fakeClickHouseStateMu.Unlock()
|
|
if len(queries) != 2 {
|
|
t.Fatalf("expected two queries, got %v", queries)
|
|
}
|
|
if queries[0] != listSQL || queries[1] != fallbackSQL {
|
|
t.Fatalf("unexpected query order: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestDetectClickHouseProtocolTreatsHTTPPortsAsHTTP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config connection.ConnectionConfig
|
|
expected clickhouse.Protocol
|
|
}{
|
|
{
|
|
name: "http uri",
|
|
config: connection.ConnectionConfig{
|
|
URI: "http://127.0.0.1:8132/default",
|
|
},
|
|
expected: clickhouse.HTTP,
|
|
},
|
|
{
|
|
name: "default http port",
|
|
config: connection.ConnectionConfig{
|
|
Port: 8123,
|
|
},
|
|
expected: clickhouse.HTTP,
|
|
},
|
|
{
|
|
name: "alternate http port 8132",
|
|
config: connection.ConnectionConfig{
|
|
Port: 8132,
|
|
},
|
|
expected: clickhouse.HTTP,
|
|
},
|
|
{
|
|
name: "custom http port 8125",
|
|
config: connection.ConnectionConfig{
|
|
Port: 8125,
|
|
},
|
|
expected: clickhouse.HTTP,
|
|
},
|
|
{
|
|
name: "https port",
|
|
config: connection.ConnectionConfig{
|
|
Port: 8443,
|
|
},
|
|
expected: clickhouse.HTTP,
|
|
},
|
|
{
|
|
name: "native port",
|
|
config: connection.ConnectionConfig{
|
|
Port: 9000,
|
|
},
|
|
expected: clickhouse.Native,
|
|
},
|
|
{
|
|
name: "native tls port",
|
|
config: connection.ConnectionConfig{
|
|
Port: 9440,
|
|
},
|
|
expected: clickhouse.Native,
|
|
},
|
|
{
|
|
name: "host http scheme",
|
|
config: connection.ConnectionConfig{
|
|
Host: "http://clickhouse.example.com",
|
|
Port: 8125,
|
|
},
|
|
expected: clickhouse.HTTP,
|
|
},
|
|
{
|
|
name: "manual http overrides native port",
|
|
config: connection.ConnectionConfig{
|
|
ClickHouseProtocol: "http",
|
|
Port: 9000,
|
|
},
|
|
expected: clickhouse.HTTP,
|
|
},
|
|
{
|
|
name: "manual native overrides http port",
|
|
config: connection.ConnectionConfig{
|
|
ClickHouseProtocol: "native",
|
|
Port: 8123,
|
|
},
|
|
expected: clickhouse.Native,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if protocol := detectClickHouseProtocol(tt.config); protocol != tt.expected {
|
|
t.Fatalf("expected protocol %s, got %s", tt.expected.String(), protocol.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeClickHouseConfigParsesHTTPHostScheme(t *testing.T) {
|
|
config := normalizeClickHouseConfig(connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Host: "https://clickhouse.example.com:8125/analytics",
|
|
User: "alice",
|
|
Password: "secret",
|
|
})
|
|
|
|
if config.Host != "clickhouse.example.com" {
|
|
t.Fatalf("expected host without scheme, got %q", config.Host)
|
|
}
|
|
if config.Port != 8125 {
|
|
t.Fatalf("expected port 8125, got %d", config.Port)
|
|
}
|
|
if config.Database != "analytics" {
|
|
t.Fatalf("expected database analytics, got %q", config.Database)
|
|
}
|
|
if config.ClickHouseProtocol != "http" {
|
|
t.Fatalf("expected http protocol hint, got %q", config.ClickHouseProtocol)
|
|
}
|
|
if !config.UseSSL || config.SSLMode != sslModeRequired {
|
|
t.Fatalf("expected https host to enable required SSL, got useSSL=%v sslMode=%q", config.UseSSL, config.SSLMode)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeClickHouseConfigKeepsManualNativeWhenHostHasHTTPScheme(t *testing.T) {
|
|
config := normalizeClickHouseConfig(connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Host: "http://clickhouse.example.com:9001/analytics",
|
|
ClickHouseProtocol: "native",
|
|
User: "alice",
|
|
Password: "secret",
|
|
})
|
|
|
|
if config.Host != "clickhouse.example.com" {
|
|
t.Fatalf("expected host without scheme, got %q", config.Host)
|
|
}
|
|
if config.Port != 9001 {
|
|
t.Fatalf("expected user-provided native port 9001, got %d", config.Port)
|
|
}
|
|
if config.Database != "analytics" {
|
|
t.Fatalf("expected database analytics, got %q", config.Database)
|
|
}
|
|
if config.ClickHouseProtocol != "native" {
|
|
t.Fatalf("expected manual native protocol to be preserved, got %q", config.ClickHouseProtocol)
|
|
}
|
|
if config.UseSSL {
|
|
t.Fatalf("manual native protocol should not be forced to HTTP TLS by http scheme")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeClickHouseConfigUsesNativeDefaultPortForManualNativeHTTPScheme(t *testing.T) {
|
|
config := normalizeClickHouseConfig(connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Host: "https://clickhouse.example.com/analytics",
|
|
ClickHouseProtocol: "native",
|
|
})
|
|
|
|
if config.Host != "clickhouse.example.com" {
|
|
t.Fatalf("expected host without scheme, got %q", config.Host)
|
|
}
|
|
if config.Port != defaultClickHousePort {
|
|
t.Fatalf("expected native default port %d, got %d", defaultClickHousePort, config.Port)
|
|
}
|
|
if config.ClickHouseProtocol != "native" {
|
|
t.Fatalf("expected manual native protocol to be preserved, got %q", config.ClickHouseProtocol)
|
|
}
|
|
}
|
|
|
|
func TestClickHouseProtocolMismatchIncludesHTTPParseBinaryResponse(t *testing.T) {
|
|
err := errors.New("code: 27, message: Cannot parse input: expected '(' before: '\x02\x00\x01\x00'")
|
|
if !isClickHouseProtocolMismatch(err) {
|
|
t.Fatalf("expected binary parse response to be treated as protocol mismatch")
|
|
}
|
|
|
|
message := clickHouseAttemptFailureMessage(clickhouse.Native, err)
|
|
if !strings.Contains(message, "不像 Native") || strings.Contains(message, "\x00") {
|
|
t.Fatalf("expected user-facing native mismatch message without binary bytes, got %q", message)
|
|
}
|
|
}
|
|
|
|
func TestWithClickHouseProtocolForcesProtocolSelection(t *testing.T) {
|
|
httpConfig := withClickHouseProtocol(connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Host: "clickhouse.example.com",
|
|
Port: 8125,
|
|
}, clickhouse.HTTP)
|
|
if protocol := detectClickHouseProtocol(httpConfig); protocol != clickhouse.HTTP {
|
|
t.Fatalf("expected forced HTTP protocol, got %s", protocol.String())
|
|
}
|
|
|
|
nativeConfig := withClickHouseProtocol(connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Host: "http://clickhouse.example.com",
|
|
Port: 8125,
|
|
}, clickhouse.Native)
|
|
if protocol := detectClickHouseProtocol(nativeConfig); protocol != clickhouse.Native {
|
|
t.Fatalf("expected forced Native protocol, got %s", protocol.String())
|
|
}
|
|
}
|
|
|
|
func TestClickHouseProtocolsForAttemptOnlyFallsBackInAutoMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config connection.ConnectionConfig
|
|
expected []clickhouse.Protocol
|
|
}{
|
|
{
|
|
name: "auto native falls back to http",
|
|
config: connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Port: 9000,
|
|
},
|
|
expected: []clickhouse.Protocol{clickhouse.Native, clickhouse.HTTP},
|
|
},
|
|
{
|
|
name: "auto http falls back to native",
|
|
config: connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Port: 8125,
|
|
},
|
|
expected: []clickhouse.Protocol{clickhouse.HTTP, clickhouse.Native},
|
|
},
|
|
{
|
|
name: "manual http does not try native",
|
|
config: connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Port: 9000,
|
|
ClickHouseProtocol: "http",
|
|
},
|
|
expected: []clickhouse.Protocol{clickhouse.HTTP},
|
|
},
|
|
{
|
|
name: "manual native does not try http",
|
|
config: connection.ConnectionConfig{
|
|
Type: "clickhouse",
|
|
Port: 8125,
|
|
ClickHouseProtocol: "native",
|
|
},
|
|
expected: []clickhouse.Protocol{clickhouse.Native},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := clickHouseProtocolsForAttempt(tt.config)
|
|
if len(got) != len(tt.expected) {
|
|
t.Fatalf("expected protocols %v, got %v", protocolNames(tt.expected), protocolNames(got))
|
|
}
|
|
for idx := range got {
|
|
if got[idx] != tt.expected[idx] {
|
|
t.Fatalf("expected protocols %v, got %v", protocolNames(tt.expected), protocolNames(got))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func protocolNames(protocols []clickhouse.Protocol) []string {
|
|
names := make([]string, 0, len(protocols))
|
|
for _, protocol := range protocols {
|
|
names = append(names, protocol.String())
|
|
}
|
|
return names
|
|
}
|
|
|
|
type fakeClickHouseDriver struct{}
|
|
|
|
func (fakeClickHouseDriver) Open(name string) (driver.Conn, error) {
|
|
return fakeClickHouseConn{}, nil
|
|
}
|
|
|
|
type fakeClickHouseConn struct{}
|
|
|
|
func (fakeClickHouseConn) Prepare(query string) (driver.Stmt, error) {
|
|
return nil, errors.New("prepare not implemented")
|
|
}
|
|
|
|
func (fakeClickHouseConn) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (fakeClickHouseConn) Begin() (driver.Tx, error) {
|
|
return nil, errors.New("transactions not implemented")
|
|
}
|
|
|
|
func (fakeClickHouseConn) Ping(ctx context.Context) error {
|
|
fakeClickHouseStateMu.Lock()
|
|
defer fakeClickHouseStateMu.Unlock()
|
|
return fakeClickHouseState.pingErr
|
|
}
|
|
|
|
func (fakeClickHouseConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
|
fakeClickHouseStateMu.Lock()
|
|
defer fakeClickHouseStateMu.Unlock()
|
|
fakeClickHouseState.lastQuery = query
|
|
fakeClickHouseState.queries = append(fakeClickHouseState.queries, query)
|
|
if result, ok := fakeClickHouseState.queryResults[query]; ok {
|
|
if result.err != nil {
|
|
return nil, result.err
|
|
}
|
|
return &fakeClickHouseRows{columns: result.columns, rows: result.rows}, nil
|
|
}
|
|
if fakeClickHouseState.queryErr != nil {
|
|
return nil, fakeClickHouseState.queryErr
|
|
}
|
|
return &fakeClickHouseRows{}, nil
|
|
}
|
|
|
|
type fakeClickHouseRows struct {
|
|
columns []string
|
|
rows [][]driver.Value
|
|
index int
|
|
}
|
|
|
|
func (r *fakeClickHouseRows) Columns() []string {
|
|
if len(r.columns) > 0 {
|
|
return r.columns
|
|
}
|
|
return []string{"currentDatabase"}
|
|
}
|
|
|
|
func (r *fakeClickHouseRows) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeClickHouseRows) Next(dest []driver.Value) error {
|
|
if r.index < len(r.rows) {
|
|
row := r.rows[r.index]
|
|
for idx := range dest {
|
|
if idx < len(row) {
|
|
dest[idx] = row[idx]
|
|
}
|
|
}
|
|
r.index++
|
|
return nil
|
|
}
|
|
if len(dest) > 0 {
|
|
dest[0] = "default"
|
|
}
|
|
return io.EOF
|
|
}
|