mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 01:11:31 +08:00
# 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
1082 lines
33 KiB
Go
1082 lines
33 KiB
Go
//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
|
||
}
|