Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion clickhouse_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,11 @@ func (o *Options) fromDSN(in string) error {
o.Auth.Username = dsn.User.Username()
o.Auth.Password, _ = dsn.User.Password()
}
o.Addr = append(o.Addr, strings.Split(dsn.Host, ",")...)
// Prefer hosts from the raw DSN authority so multi-host (HA) lists stay
// intact. net/url (and copies of it) can collapse multi-host IPv6 authorities
// to the last host; comma-split of Host also breaks when host values contain
// commas only as HA separators. See https://github.com/ClickHouse/clickhouse-go/issues/1784
o.Addr = append(o.Addr, dsnAddrList(in, dsn.Host)...)
var (
secure bool
params = dsn.Query()
Expand Down Expand Up @@ -398,6 +402,71 @@ func (o *Options) fromDSN(in string) error {
return nil
}

// dsnAddrList returns HA host addresses for a DSN.
// It extracts the authority host list from the raw DSN when possible so that
// multi-host URLs (including bracketed IPv6) are not lost to URL Host normalization.
// Falls back to splitting the already-parsed host on commas.
func dsnAddrList(rawDSN, parsedHost string) []string {
if addrs := addrListFromDSN(rawDSN); len(addrs) > 0 {
return addrs
}
if parsedHost == "" {
return nil
}
return strings.Split(parsedHost, ",")
}

// addrListFromDSN extracts host[:port] entries from the DSN authority.
// Supports comma-separated HA hosts and bracketed IPv6 literals.
func addrListFromDSN(raw string) []string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are already tools that can parse such form of url:

package main

import (
	"fmt"
	"net"
	"net/url"
	"strings"
)

type TargetHost struct {
	Host string
	Port string
}

func main() {
	rawURL := "clickhouse://user:pass@host1:9440,host2:9440/database?secure=true"

	// 1. Parse into a standard URL structure
	u, err := url.Parse(rawURL)
	if err != nil {
		panic(err)
	}

	// 2. Extract Username
	username := ""
	if u.User != nil {
		username = u.User.Username()
	}

	// 3. Extract the complete host cluster string
	// u.Host will contain "host1:9440,host2:9440"
	clusterStr := u.Host

	// 4. Split the individual hosts by comma
	hostTokens := strings.Split(clusterStr, ",")
	var hosts []TargetHost

	for _, token := range hostTokens {
		h, p, err := net.SplitHostPort(token)
		if err != nil {
			// Fallback if port is missing from a specific host token
			h = token
			p = ""
		}
		hosts = append(hosts, TargetHost{Host: h, Port: p})
	}

	// Output results
	fmt.Println("Username:", username)
	fmt.Println("Database:", strings.TrimPrefix(u.Path, "/"))
	fmt.Println("Hosts found:")
	for i, h := range hosts {
		fmt.Printf("  [%d] Host: %s, Port: %s\n", i, h.Host, h.Port)
	}
}

i := strings.Index(raw, "://")
if i < 0 {
return nil
}
rest := raw[i+3:]
if j := strings.IndexAny(rest, "?#"); j >= 0 {
rest = rest[:j]
}
if j := strings.Index(rest, "/"); j >= 0 {
rest = rest[:j]
}
if j := strings.LastIndex(rest, "@"); j >= 0 {
rest = rest[j+1:]
}
if rest == "" {
return nil
}
return splitHostList(rest)
}

// splitHostList splits a comma-separated host list without breaking bracketed IPv6 addresses.
func splitHostList(s string) []string {
var out []string
start := 0
depth := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '[':
depth++
case ']':
if depth > 0 {
depth--
}
case ',':
if depth == 0 {
if part := strings.TrimSpace(s[start:i]); part != "" {
out = append(out, part)
}
start = i + 1
}
}
}
if part := strings.TrimSpace(s[start:]); part != "" {
out = append(out, part)
}
return out
}

// receive copy of Options, so we don't modify original - so its reusable
func (o Options) setDefaults() *Options {
if len(o.Auth.Username) == 0 {
Expand Down
98 changes: 98 additions & 0 deletions clickhouse_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,57 @@ func TestParseDSN(t *testing.T) {
},
"",
},
// Regression for https://github.com/ClickHouse/clickhouse-go/issues/1784

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do not name tests after issues - tests should match functionality
please remove.

// (Go 1.26 net/url multi-host / HA DSN). Also covers auth + query.
{
"HA multi-host with auth and secure (issue 1784)",
"clickhouse://user:pass@host1:9440,host2:9440/database?secure=true",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what user and password would be used for second host?
what if credentials are overridden in options?
There should be tests for all this cases.

&Options{
Protocol: Native,
TLS: &tls.Config{
InsecureSkipVerify: false,
},
Addr: []string{"host1:9440", "host2:9440"},
Settings: Settings{},
Auth: Auth{
Username: "user",
Password: "pass",
Database: "database",
},
scheme: "clickhouse",
},
"",
},
{
"HA multi-host HTTP scheme",
"http://host1:8123,host2:8123/db",
&Options{
Protocol: HTTP,
TLS: nil,
Addr: []string{"host1:8123", "host2:8123"},
Settings: Settings{},
Auth: Auth{
Database: "db",
},
scheme: "http",
},
"",
},
{
"HA multi-host IPv6",
"clickhouse://[::1]:9440,[2001:db8::1]:9440/test_database",
&Options{
Protocol: Native,
TLS: nil,
Addr: []string{"[::1]:9440", "[2001:db8::1]:9440"},
Settings: Settings{},
Auth: Auth{
Database: "test_database",
},
scheme: "clickhouse",
},
"",
},
}

for _, testCase := range testCases {
Expand All @@ -587,6 +638,53 @@ func parseURL(t *testing.T, v string) *url.URL {
return u
}

func TestDSNAddrList(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
parsedHost string
want []string
}{
{
name: "issue 1784 multi-host",
raw: "clickhouse://host1:9440,host2:9440/database",
parsedHost: "host1:9440,host2:9440",
want: []string{"host1:9440", "host2:9440"},
},
{
name: "multi-host with userinfo",
raw: "clickhouse://user:pass@host1:9440,host2:9440/db?secure=true",
parsedHost: "host1:9440,host2:9440",
want: []string{"host1:9440", "host2:9440"},
},
{
name: "bracketed IPv6 multi-host",
raw: "clickhouse://[::1]:9440,[2001:db8::1]:9440/db",
parsedHost: "[2001:db8::1]:9440", // parsers may keep only the last host
want: []string{"[::1]:9440", "[2001:db8::1]:9440"},
},
{
name: "fallback to parsed host",
raw: "not-a-url",
parsedHost: "only-host:9000",
want: []string{"only-host:9000"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, dsnAddrList(tc.raw, tc.parsedHost))
})
}
}

func TestSplitHostList(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{"a:1", "b:2"}, splitHostList("a:1,b:2"))
assert.Equal(t, []string{"[::1]:1", "[::2]:2"}, splitHostList("[::1]:1,[::2]:2"))
assert.Equal(t, []string{"single"}, splitHostList("single"))
}

func TestLogger(t *testing.T) {
t.Run("debug=1 via DSN produces non-noop logger", func(t *testing.T) {
opts, err := ParseDSN("clickhouse://127.0.0.1/test?debug=1")
Expand Down
46 changes: 46 additions & 0 deletions lib/churl/churl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package churl

import (
"testing"
)

// Multi-host (HA) DSNs must parse under churl so clickhouse-go remains compatible
// when net/url rejects or normalizes multi-host authorities (see Go 1.26 / #1784).
func TestParseMultiHost(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
host string
path string
}{
{
raw: "clickhouse://host1:9440,host2:9440/database",
host: "host1:9440,host2:9440",
path: "/database",
},
{
raw: "clickhouse://user:pass@host1:9440,host2:9440/db?secure=true",
host: "host1:9440,host2:9440",
path: "/db",
},
{
raw: "http://host1:8123,host2:8123/db",
host: "host1:8123,host2:8123",
path: "/db",
},
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
u, err := Parse(tc.raw)
if err != nil {
t.Fatalf("Parse(%q): %v", tc.raw, err)
}
if u.Host != tc.host {
t.Fatalf("Host: got %q want %q", u.Host, tc.host)
}
if u.Path != tc.path {
t.Fatalf("Path: got %q want %q", u.Path, tc.path)
}
})
}
}