Files
MyGoNavi/internal/db/clickhouse_impl_test.go
tianqijiuyun-latiao 0ba984b277 Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/AISettingsModal.tsx
#	frontend/src/components/ConnectionModal.edit-password.test.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataSyncModal.i18n.test.ts
#	frontend/src/components/DataSyncModal.tsx
#	frontend/src/components/QueryEditor.external-sql-save.test.tsx
#	frontend/src/components/QueryEditor.tsx
#	frontend/src/components/Sidebar.locate-toolbar.test.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/components/SnippetSettingsModal.tsx
#	frontend/src/components/TableOverview.tsx
#	frontend/src/components/ai/AIChatHeader.test.tsx
#	frontend/src/components/ai/AISettingsProvidersSection.tsx
#	frontend/src/components/ai/aiChatPayloadDispatch.ts
#	frontend/src/components/ai/aiChatReadiness.ts
#	frontend/src/components/ai/aiSettingsModalConfig.tsx
#	frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx
#	frontend/src/components/sidebarV2Utils.ts
#	frontend/src/i18n/catalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.ts
#	frontend/src/utils/tabDisplay.ts
#	internal/ai/provider/custom.go
#	internal/ai/service/service.go
#	internal/app/methods_driver.go
#	internal/app/methods_file.go
#	internal/db/custom_impl.go
#	internal/db/iris_impl.go
#	internal/db/mariadb_impl.go
#	internal/db/sqlserver_impl.go
#	shared/i18n/de-DE.json
#	shared/i18n/en-US.json
#	shared/i18n/ja-JP.json
#	shared/i18n/ru-RU.json
#	shared/i18n/zh-CN.json
#	shared/i18n/zh-TW.json
2026-06-23 12:41:27 +08:00

1082 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build gonavi_full_drivers || gonavi_clickhouse_driver
package db
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"io"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
)
const fakeClickHouseDriverName = "gonavi-fake-clickhouse"
var clickHouseProtocolFailureI18nKeys = []string{
"db.backend.error.clickhouse_http_client_protocol_version_unsupported",
"db.backend.error.clickhouse_native_protocol_mismatch",
"db.backend.error.clickhouse_http_protocol_mismatch",
"db.backend.error.clickhouse_unknown_error",
"db.backend.error.clickhouse_driver_detail_missing",
"db.backend.error.clickhouse_attempt_tls_config_failed",
"db.backend.error.clickhouse_attempt_validation_failed",
"db.backend.error.clickhouse_validation_failed_manual",
"db.backend.error.clickhouse_validation_failed_auto",
}
const rawClickHouseCreateStatementNotFoundText = "未找到建表语句"
var (
registerFakeClickHouseDriverOnce sync.Once
fakeClickHouseStateMu sync.Mutex
fakeClickHouseState = struct {
pingErr error
queryErr error
execErr error
queryResults map[string]fakeClickHouseQueryResult
lastQuery string
queries []string
lastExec string
execQueries []string
}{
lastQuery: "",
queryResults: map[string]fakeClickHouseQueryResult{},
queries: nil,
lastExec: "",
execQueries: 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 TestClickHouseCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
tests := []struct {
name string
result fakeClickHouseQueryResult
}{
{
name: "empty rows",
result: fakeClickHouseQueryResult{
columns: []string{"statement"},
rows: nil,
},
},
{
name: "row without CREATE statement",
result: fakeClickHouseQueryResult{
columns: []string{"note"},
rows: [][]driver.Value{
{"SELECT 1"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
registerFakeClickHouseDriverOnce.Do(func() {
sql.Register(fakeClickHouseDriverName, fakeClickHouseDriver{})
})
conn, err := sql.Open(fakeClickHouseDriverName, "")
if err != nil {
t.Fatalf("open fake clickhouse db failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
const showCreateSQL = "SHOW CREATE TABLE `app`.`orders`"
fakeClickHouseStateMu.Lock()
fakeClickHouseState.pingErr = nil
fakeClickHouseState.queryErr = nil
fakeClickHouseState.queryResults = map[string]fakeClickHouseQueryResult{
showCreateSQL: tt.result,
}
fakeClickHouseState.lastQuery = ""
fakeClickHouseState.queries = nil
fakeClickHouseStateMu.Unlock()
clickhouseDB := &ClickHouseDB{conn: conn}
_, err = clickhouseDB.GetCreateStatement("app", "orders")
if err == nil {
t.Fatal("expected ClickHouse GetCreateStatement to fail")
}
if err.Error() != "The CREATE TABLE statement was not found" {
t.Fatalf("expected English create-statement error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawClickHouseCreateStatementNotFoundText) {
t.Fatalf("expected no raw Chinese create-statement text, got %q", err.Error())
}
})
}
}
func TestClickHouseCreateStatementSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("clickhouse_impl.go")
if err != nil {
t.Fatalf("read clickhouse_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawClickHouseCreateStatementNotFoundText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("clickhouse_impl.go still contains raw create-statement text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("clickhouse_impl.go does not reference db.backend.error.create_table_statement_not_found")
}
}
func TestClickHouseApplyChangesErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
registerFakeClickHouseDriverOnce.Do(func() {
sql.Register(fakeClickHouseDriverName, fakeClickHouseDriver{})
})
tests := []struct {
name string
changes connection.ChangeSet
wantText string
forbiddenRaw []string
}{
{
name: "delete failure",
changes: connection.ChangeSet{
Deletes: []map[string]interface{}{
{"id": int64(42)},
},
},
wantText: "Failed to delete ClickHouse rows",
forbiddenRaw: []string{"delete error", "删除失败"},
},
{
name: "update failure",
changes: connection.ChangeSet{
Updates: []connection.UpdateRow{
{
Keys: map[string]interface{}{"id": int64(42)},
Values: map[string]interface{}{"name": "Alice"},
},
},
},
wantText: "Failed to update ClickHouse rows",
forbiddenRaw: []string{"update error", "更新失败"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conn, err := sql.Open(fakeClickHouseDriverName, "")
if err != nil {
t.Fatalf("open fake clickhouse db failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
fakeClickHouseStateMu.Lock()
fakeClickHouseState.execErr = errors.New("driver raw failure")
fakeClickHouseState.lastExec = ""
fakeClickHouseState.execQueries = nil
fakeClickHouseStateMu.Unlock()
clickhouseDB := &ClickHouseDB{conn: conn, database: "analytics"}
err = clickhouseDB.ApplyChanges("orders", tt.changes)
if err == nil {
t.Fatal("expected ApplyChanges to fail")
}
got := err.Error()
if !strings.Contains(got, tt.wantText) {
t.Fatalf("expected localized wrapper %q, got %q", tt.wantText, got)
}
if !strings.Contains(got, "driver raw failure") {
t.Fatalf("expected raw driver detail to remain, got %q", got)
}
if !strings.Contains(got, "ALTER TABLE `analytics`.`orders`") {
t.Fatalf("expected raw SQL to remain, got %q", got)
}
for _, raw := range tt.forbiddenRaw {
if strings.Contains(got, raw) {
t.Fatalf("expected no raw wrapper %q, got %q", raw, got)
}
}
})
}
}
func TestClickHouseTableNameRequiredUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
clickhouseDB := &ClickHouseDB{}
_, _, err := clickhouseDB.resolveDatabaseAndTable("", " ")
if err == nil {
t.Fatal("expected table-name-required error")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawClickHouseTableNameRequiredText()) {
t.Fatalf("expected no raw Chinese table-name-required text, got %q", err.Error())
}
}
func TestClickHouseApplyChangesErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("clickhouse_impl.go")
if err != nil {
t.Fatalf("read clickhouse_impl.go: %v", err)
}
source := string(sourceBytes)
for _, rawMessage := range []string{
`fmt.Errorf("` + rawClickHouseTableNameRequiredText() + `")`,
`fmt.Errorf("delete error: %v; sql=%s", err, query)`,
`fmt.Errorf("update error: %v; sql=%s", err, query)`,
} {
if strings.Contains(source, rawMessage) {
t.Fatalf("clickhouse_impl.go still contains raw ApplyChanges text %q", rawMessage)
}
}
for _, key := range clickHouseApplyChangesI18nKeys() {
if !strings.Contains(source, key) {
t.Fatalf("clickhouse_impl.go does not reference i18n key %q", key)
}
}
}
func TestClickHouseApplyChangesCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range clickHouseApplyChangesI18nKeys() {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing ClickHouse ApplyChanges key %q", language, key)
}
}
}
}
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 TestClickHouseHTTPClientProtocolVersionUnsupportedEnablesCompatibilityRetry(t *testing.T) {
err := errors.New(`failed to query server hello: failed to query server hello info: sendQuery: [HTTP 404] response body: "Code: 115. DB::Exception: Unknown setting client_protocol_version. (UNKNOWN_SETTING)"`)
if !isClickHouseHTTPClientProtocolVersionUnsupported(err) {
t.Fatalf("expected client_protocol_version unknown setting to be treated as HTTP compatibility issue")
}
if !shouldTryNextClickHouseProtocol(clickhouse.HTTP, err) {
t.Fatalf("expected HTTP client_protocol_version issue to permit protocol fallback")
}
if shouldTryNextClickHouseProtocol(clickhouse.Native, err) {
t.Fatalf("native protocol should not treat HTTP client_protocol_version issue as retryable")
}
message := clickHouseAttemptFailureMessage(clickhouse.HTTP, err)
if !strings.Contains(message, "client_protocol_version") || !strings.Contains(message, "兼容模式") {
t.Fatalf("expected compatibility retry hint, got %q", message)
}
}
func TestClickHouseProtocolFailureMessagesUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
clientProtocolErr := errors.New(`Code: 115. DB::Exception: Unknown setting client_protocol_version. (UNKNOWN_SETTING)`)
compatMessage := clickHouseAttemptFailureMessage(clickhouse.HTTP, clientProtocolErr)
if !strings.Contains(compatMessage, "client_protocol_version") || !strings.Contains(compatMessage, "HTTP compatibility mode") {
t.Fatalf("expected English compatibility hint, got %q", compatMessage)
}
if strings.Contains(compatMessage, "兼容模式") || strings.Contains(compatMessage, "当前") {
t.Fatalf("expected no Chinese compatibility hint, got %q", compatMessage)
}
nativeMismatch := clickHouseAttemptFailureMessage(clickhouse.Native, errors.New("code: 27, message: Cannot parse input: expected '(' before: '\x02\x00\x01\x00'"))
if !strings.Contains(nativeMismatch, "does not look like a Native handshake") {
t.Fatalf("expected English native mismatch hint, got %q", nativeMismatch)
}
if strings.Contains(nativeMismatch, "不像 Native") {
t.Fatalf("expected no Chinese native mismatch hint, got %q", nativeMismatch)
}
httpMismatch := clickHouseAttemptFailureMessage(clickhouse.HTTP, errors.New("malformed HTTP response"))
if !strings.Contains(httpMismatch, "does not look like an HTTP response") {
t.Fatalf("expected English HTTP mismatch hint, got %q", httpMismatch)
}
if strings.Contains(httpMismatch, "不像 HTTP") {
t.Fatalf("expected no Chinese HTTP mismatch hint, got %q", httpMismatch)
}
unknownMessage := clickHouseAttemptFailureMessage(clickhouse.HTTP, nil)
if unknownMessage != "Unknown error" {
t.Fatalf("expected localized unknown error, got %q", unknownMessage)
}
}
func TestClickHouseConnectFailureSummaryUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
manual := clickHouseConnectFailureSummary(connection.ConnectionConfig{
Host: "clickhouse.local",
Port: 9000,
ClickHouseProtocol: clickHouseProtocolNative,
}, []string{"driver raw detail"})
if !strings.Contains(manual, "ClickHouse connection validation failed") ||
!strings.Contains(manual, "used user-selected NATIVE protocol") ||
!strings.Contains(manual, "driver raw detail") {
t.Fatalf("expected English manual protocol failure summary with raw detail, got %q", manual)
}
if strings.Contains(manual, "连接验证失败") || strings.Contains(manual, "用户选择") || strings.Contains(manual, "第1次") {
t.Fatalf("expected no Chinese manual summary, got %q", manual)
}
manualWithMultipleDetails := clickHouseConnectFailureSummary(connection.ConnectionConfig{
Host: "clickhouse.local",
Port: 9000,
ClickHouseProtocol: clickHouseProtocolNative,
}, []string{"first raw detail", "second raw detail"})
if !strings.Contains(manualWithMultipleDetails, "first raw detail; second raw detail") {
t.Fatalf("expected ASCII separator between raw details, got %q", manualWithMultipleDetails)
}
if strings.Contains(manualWithMultipleDetails, "") {
t.Fatalf("expected no Chinese separator between raw details, got %q", manualWithMultipleDetails)
}
auto := clickHouseConnectFailureSummary(connection.ConnectionConfig{
Host: "clickhouse.local",
Port: 8123,
}, nil)
if !strings.Contains(auto, "Automatic protocol detection failed") ||
!strings.Contains(auto, "No driver error details were returned") {
t.Fatalf("expected English auto protocol failure summary, got %q", auto)
}
if strings.Contains(auto, "自动协议探测") || strings.Contains(auto, "未获取到") {
t.Fatalf("expected no Chinese auto summary, got %q", auto)
}
}
func TestClickHouseProtocolFailureSourceUsesI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("clickhouse_impl.go")
if err != nil {
t.Fatalf("read clickhouse_impl.go: %v", err)
}
source := string(sourceBytes)
for _, rawMessage := range []string{
"当前 ClickHouse HTTP 端口不支持 client_protocol_version",
"服务端响应不像 Native 握手",
"服务端响应不像 HTTP 响应",
"未知错误",
"未获取到驱动返回的错误详情",
"ClickHouse 连接验证失败",
"第%d次 TLS 配置失败",
"第%d次连接验证失败",
} {
if strings.Contains(source, rawMessage) {
t.Fatalf("clickhouse_impl.go still contains raw user-facing ClickHouse protocol text %q", rawMessage)
}
}
for _, key := range clickHouseProtocolFailureI18nKeys {
if !strings.Contains(source, key) {
t.Fatalf("clickhouse_impl.go does not reference i18n key %q", key)
}
}
}
func TestClickHouseProtocolFailureCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range clickHouseProtocolFailureI18nKeys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing ClickHouse protocol failure key %q", language, key)
}
}
}
}
func TestClickHouseHTTPClientProtocolVersionStripperRemovesDriverQueryParam(t *testing.T) {
var seenQuery string
stripper := clickHouseHTTPClientProtocolVersionStripper{
next: roundTripFunc(func(req *http.Request) (*http.Response, error) {
seenQuery = req.URL.RawQuery
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}),
}
req, err := http.NewRequest(http.MethodPost, "http://clickhouse.local:8123/?database=default&client_protocol_version=54485", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
res, err := stripper.RoundTrip(req)
if err != nil {
t.Fatalf("round trip: %v", err)
}
if res != nil && res.Body != nil {
res.Body.Close()
}
if strings.Contains(seenQuery, "client_protocol_version") {
t.Fatalf("expected client_protocol_version stripped from query, got %q", seenQuery)
}
if !strings.Contains(seenQuery, "database=default") {
t.Fatalf("expected other query parameters to remain, got %q", seenQuery)
}
}
func TestClickHouseHTTPServerInfoFunctionUnsupportedEnablesCompatibilityRetry(t *testing.T) {
err := errors.New(`failed to query server hello: failed to query server hello info: sendQuery: [HTTP 404] response body: "Code: 46. DB::Exception: Unknown function displayName: While processing displayName(), version(), revision(), timezone(). (UNKNOWN_FUNCTION)"`)
if !isClickHouseHTTPServerInfoFunctionUnsupported(err) {
t.Fatalf("expected displayName unknown function to be treated as HTTP server-info compatibility issue")
}
if !shouldRetryClickHouseHTTPCompatibility(err) {
t.Fatalf("expected displayName unknown function to permit HTTP compatibility retry")
}
if !shouldTryNextClickHouseProtocol(clickhouse.HTTP, err) {
t.Fatalf("expected HTTP displayName issue to permit protocol fallback")
}
if shouldTryNextClickHouseProtocol(clickhouse.Native, err) {
t.Fatalf("native protocol should not treat HTTP displayName issue as retryable")
}
message := clickHouseAttemptFailureMessage(clickhouse.HTTP, err)
if !strings.Contains(message, "displayName") || !strings.Contains(message, "兼容模式") {
t.Fatalf("expected displayName compatibility retry hint, got %q", message)
}
}
func TestIsClickHouseHTTPServerInfoFunctionUnsupportedIgnoresUnrelatedErrors(t *testing.T) {
if isClickHouseHTTPServerInfoFunctionUnsupported(nil) {
t.Fatal("nil error should not be treated as server-info function issue")
}
if isClickHouseHTTPServerInfoFunctionUnsupported(errors.New("[HTTP 404] page not found")) {
t.Fatal("plain 404 without displayName signal should not be treated as server-info function issue")
}
if isClickHouseHTTPServerInfoFunctionUnsupported(errors.New("Code: 60. DB::Exception: Unknown function someOtherFn")) {
t.Fatal("unknown function error without displayName should not be treated as server-info function issue")
}
}
func TestClickHouseHTTPCompatibilityStripperRewritesServerHelloQuery(t *testing.T) {
var seenBody string
stripper := clickHouseHTTPClientProtocolVersionStripper{
next: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Body != nil {
data, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
seenBody = string(data)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}),
}
req, err := http.NewRequest(
http.MethodPost,
"http://clickhouse.local:8123/?database=default",
strings.NewReader(clickHouseServerHelloQuery),
)
if err != nil {
t.Fatalf("new request: %v", err)
}
res, err := stripper.RoundTrip(req)
if err != nil {
t.Fatalf("round trip: %v", err)
}
if res != nil && res.Body != nil {
res.Body.Close()
}
if strings.Contains(seenBody, "displayName()") {
t.Fatalf("expected displayName() rewritten out of server hello query, got %q", seenBody)
}
if seenBody != clickHouseServerHelloCompatQuery {
t.Fatalf("expected compatibility server hello query, got %q", seenBody)
}
}
func TestClickHouseHTTPCompatibilityStripperRewritesServerHelloOnlyOnce(t *testing.T) {
var seenBodies []string
stripper := clickHouseHTTPClientProtocolVersionStripper{
serverHelloRewritten: &atomic.Bool{},
next: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Body != nil {
data, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
seenBodies = append(seenBodies, string(data))
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}),
}
for i := 0; i < 2; i++ {
req, err := http.NewRequest(
http.MethodPost,
"http://clickhouse.local:8123/?database=default",
strings.NewReader(clickHouseServerHelloQuery),
)
if err != nil {
t.Fatalf("new request %d: %v", i, err)
}
res, err := stripper.RoundTrip(req)
if err != nil {
t.Fatalf("round trip %d: %v", i, err)
}
if res != nil && res.Body != nil {
res.Body.Close()
}
}
if len(seenBodies) != 2 {
t.Fatalf("expected two forwarded requests, got %d", len(seenBodies))
}
if seenBodies[0] != clickHouseServerHelloCompatQuery {
t.Fatalf("expected first (handshake) request rewritten, got %q", seenBodies[0])
}
if seenBodies[1] != clickHouseServerHelloQuery {
t.Fatalf("expected second identical query left unchanged after handshake, got %q", seenBodies[1])
}
}
func TestClickHouseHTTPCompatibilityStripperLeavesOtherBodiesUnchanged(t *testing.T) {
const userQuery = "SELECT count() FROM system.tables"
var seenBody string
stripper := clickHouseHTTPClientProtocolVersionStripper{
next: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Body != nil {
data, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
seenBody = string(data)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}),
}
req, err := http.NewRequest(
http.MethodPost,
"http://clickhouse.local:8123/?database=default",
strings.NewReader(userQuery),
)
if err != nil {
t.Fatalf("new request: %v", err)
}
res, err := stripper.RoundTrip(req)
if err != nil {
t.Fatalf("round trip: %v", err)
}
if res != nil && res.Body != nil {
res.Body.Close()
}
if seenBody != userQuery {
t.Fatalf("expected user query body untouched, got %q", seenBody)
}
}
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))
}
}
})
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func protocolNames(protocols []clickhouse.Protocol) []string {
names := make([]string, 0, len(protocols))
for _, protocol := range protocols {
names = append(names, protocol.String())
}
return names
}
func rawClickHouseTableNameRequiredText() string {
return string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
}
func clickHouseApplyChangesI18nKeys() []string {
return []string{
"db.backend.error.table_name_required",
"db.backend.error.clickhouse_delete_failed_with_sql",
"db.backend.error.clickhouse_update_failed_with_sql",
}
}
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
}
func (fakeClickHouseConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
fakeClickHouseStateMu.Lock()
defer fakeClickHouseStateMu.Unlock()
fakeClickHouseState.lastExec = query
fakeClickHouseState.execQueries = append(fakeClickHouseState.execQueries, query)
if fakeClickHouseState.execErr != nil {
return nil, fakeClickHouseState.execErr
}
return driver.RowsAffected(1), 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
}