From 864de83684fb97ea9cc3980d657b06f4d10db6fb Mon Sep 17 00:00:00 2001 From: Inada Naoki Date: Mon, 31 Aug 2026 18:41:49 +0900 Subject: [PATCH 1/2] Preserve DSN parameter order --- README.md | 1 + connection.go | 41 +++++++++++++++++++++++++++++++++++------ dsn.go | 16 ++++++++++++++-- dsn_test.go | 38 ++++++++++++++++++++++++++++++++++---- 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3da0538c7..c8bc425d8 100644 --- a/README.md +++ b/README.md @@ -467,6 +467,7 @@ Rules: * The values for string variables must be quoted with `'`. * The values must also be [url.QueryEscape](http://golang.org/pkg/net/url/#QueryEscape)'ed! (which implies values of string variables must be wrapped with `%27`). +* System variables are set in the order they appear in the DSN. Examples: * `autocommit=1`: `SET autocommit=1` diff --git a/connection.go b/connection.go index 5a25c878a..27aec9d73 100644 --- a/connection.go +++ b/connection.go @@ -103,13 +103,21 @@ func (mc *mysqlConn) syncSequence() { } // Handles parameters set in DSN after the connection is established -func (mc *mysqlConn) handleParams() (err error) { +func (mc *mysqlConn) handleParams() error { + cmdSet := mc.cfg.setParamsCommand() + if cmdSet == "" { + return nil + } + return mc.exec(cmdSet) +} + +func (cfg *Config) setParamsCommand() string { var cmdSet strings.Builder - for param, val := range mc.cfg.Params { + writeParam := func(param, val string) { if cmdSet.Len() == 0 { // Heuristic: 29 chars for each other key=value to reduce reallocations - cmdSet.Grow(4 + len(param) + 3 + len(val) + 30*(len(mc.cfg.Params)-1)) + cmdSet.Grow(4 + len(param) + 3 + len(val) + 30*(len(cfg.Params)-1)) cmdSet.WriteString("SET ") } else { cmdSet.WriteString(", ") @@ -119,11 +127,32 @@ func (mc *mysqlConn) handleParams() (err error) { cmdSet.WriteString(val) } - if cmdSet.Len() > 0 { - err = mc.exec(cmdSet.String()) + if len(cfg.paramOrder) == 0 { + // Config.Params is a map, so manually constructed Config values retain + // their existing unspecified iteration order. + for param, val := range cfg.Params { + writeParam(param, val) + } + return cmdSet.String() } - return + seen := make(map[string]struct{}, len(cfg.paramOrder)) + for _, param := range cfg.paramOrder { + if val, ok := cfg.Params[param]; ok { + writeParam(param, val) + seen[param] = struct{}{} + } + } + + // Params can be modified after parsing a DSN. Apply newly added parameters + // after the parameters whose order was recorded by ParseDSN. + for param, val := range cfg.Params { + if _, ok := seen[param]; !ok { + writeParam(param, val) + } + } + + return cmdSet.String() } // markBadConn replaces errBadConnNoWrite with driver.ErrBadConn. diff --git a/dsn.go b/dsn.go index 41463a503..980c9c71b 100644 --- a/dsn.go +++ b/dsn.go @@ -19,6 +19,7 @@ import ( "math/big" "net" "net/url" + "slices" "sort" "strconv" "strings" @@ -79,6 +80,7 @@ type Config struct { compress bool // Enable zlib compression beforeConnect func(context.Context, *Config) error // Invoked before a connection is established + paramOrder []string // Order of connection parameters parsed from the DSN pubKey *rsa.PublicKey // Server public key timeTruncate time.Duration // Truncate time.Time values to the specified duration charsets []string // Connection charset. When set, this will be set in SET NAMES query @@ -160,6 +162,7 @@ func (cfg *Config) Clone() *Config { cp.Params = make(map[string]string, len(cfg.Params)) maps.Copy(cp.Params, cfg.Params) } + cp.paramOrder = slices.Clone(cfg.paramOrder) if cfg.pubKey != nil { cp.pubKey = &rsa.PublicKey{ N: new(big.Int).Set(cfg.pubKey.N), @@ -684,14 +687,23 @@ func parseDSNParams(cfg *Config, params string) (err error) { cfg.ConnectionAttributes = connectionAttributes default: + value, err = url.QueryUnescape(value) + if err != nil { + return + } + // lazy init if cfg.Params == nil { cfg.Params = make(map[string]string) } - if cfg.Params[key], err = url.QueryUnescape(value); err != nil { - return + // Keep the last occurrence of a duplicate parameter, matching the + // existing map semantics, and place it at its last position. + if i := slices.Index(cfg.paramOrder, key); i >= 0 { + cfg.paramOrder = slices.Delete(cfg.paramOrder, i, i+1) } + cfg.Params[key] = value + cfg.paramOrder = append(cfg.paramOrder, key) } } diff --git a/dsn_test.go b/dsn_test.go index 0c8ac7a04..679f6ffaa 100644 --- a/dsn_test.go +++ b/dsn_test.go @@ -22,13 +22,13 @@ var testDSNs = []struct { out *Config }{{ "username:password@protocol(address)/dbname?param=value", - &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, paramOrder: []string{"param"}}, }, { "username:password@protocol(address)/dbname?param=value&columnsWithAlias=true", - &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true}, + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true, paramOrder: []string{"param"}}, }, { "username:password@protocol(address)/dbname?param=value&columnsWithAlias=true&multiStatements=true", - &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true, MultiStatements: true}, + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true, MultiStatements: true, paramOrder: []string{"param"}}, }, { "user@unix(/path/to/socket)/dbname?charset=utf8", &Config{User: "user", Net: "unix", Addr: "/path/to/socket", DBName: "dbname", charsets: []string{"utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, @@ -67,7 +67,7 @@ var testDSNs = []struct { &Config{User: "user", Passwd: "p@/ssword", Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, }, { "unix/?arg=%2Fsome%2Fpath.ext", - &Config{Net: "unix", Addr: "/tmp/mysql.sock", Params: map[string]string{"arg": "/some/path.ext"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "unix", Addr: "/tmp/mysql.sock", Params: map[string]string{"arg": "/some/path.ext"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, paramOrder: []string{"arg"}}, }, { "tcp(127.0.0.1)/dbname", &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, @@ -382,6 +382,31 @@ func TestParamsAreSorted(t *testing.T) { } } +func TestDSNParamOrder(t *testing.T) { + dsn := "/?aurora_read_replica_read_committed=1&transaction_isolation=%27READ-COMMITTED%27" + cfg, err := ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + + want := "SET aurora_read_replica_read_committed = 1, transaction_isolation = 'READ-COMMITTED'" + if got := cfg.setParamsCommand(); got != want { + t.Fatalf("setParamsCommand() = %q, want %q", got, want) + } +} + +func TestDuplicateDSNParamOrder(t *testing.T) { + cfg, err := ParseDSN("/?first=old&second=2&first=new") + if err != nil { + t.Fatal(err) + } + + want := "SET second = 2, first = new" + if got := cfg.setParamsCommand(); got != want { + t.Fatalf("setParamsCommand() = %q, want %q", got, want) + } +} + func TestCloneConfig(t *testing.T) { RegisterServerPubKey("testKey", testPubKeyRSA) defer DeregisterServerPubKey("testKey") @@ -417,6 +442,11 @@ func TestCloneConfig(t *testing.T) { t.Errorf("custom params in cloned Config should not propagate to original Config") } + cfg2.paramOrder[0] = "changed" + if cfg.paramOrder[0] == cfg2.paramOrder[0] { + t.Errorf("param order in cloned Config should not propagate to original Config") + } + if !reflect.DeepEqual(cfg.pubKey, cfg2.pubKey) { t.Errorf("public key in Config should be identical") } From 6d6b73e3684e452afa0e3ccd06515d3f0ea448de Mon Sep 17 00:00:00 2001 From: Inada Naoki Date: Mon, 31 Aug 2026 21:34:17 +0900 Subject: [PATCH 2/2] Preserve parameter order in FormatDSN --- README.md | 3 ++- connection.go | 25 ++------------------- dsn.go | 60 ++++++++++++++++++++++++++++++++++++++++++--------- dsn_test.go | 45 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index c8bc425d8..e033d2a2e 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,8 @@ Rules: * The values for string variables must be quoted with `'`. * The values must also be [url.QueryEscape](http://golang.org/pkg/net/url/#QueryEscape)'ed! (which implies values of string variables must be wrapped with `%27`). -* System variables are set in the order they appear in the DSN. +* System variables are set and retained by `FormatDSN` in the order they appear in the DSN. + Use `Config.Apply(AddParam(name, value))` to preserve order when adding them programmatically. Examples: * `autocommit=1`: `SET autocommit=1` diff --git a/connection.go b/connection.go index 27aec9d73..daff945ec 100644 --- a/connection.go +++ b/connection.go @@ -127,29 +127,8 @@ func (cfg *Config) setParamsCommand() string { cmdSet.WriteString(val) } - if len(cfg.paramOrder) == 0 { - // Config.Params is a map, so manually constructed Config values retain - // their existing unspecified iteration order. - for param, val := range cfg.Params { - writeParam(param, val) - } - return cmdSet.String() - } - - seen := make(map[string]struct{}, len(cfg.paramOrder)) - for _, param := range cfg.paramOrder { - if val, ok := cfg.Params[param]; ok { - writeParam(param, val) - seen[param] = struct{}{} - } - } - - // Params can be modified after parsing a DSN. Apply newly added parameters - // after the parameters whose order was recorded by ParseDSN. - for param, val := range cfg.Params { - if _, ok := seen[param]; !ok { - writeParam(param, val) - } + for _, param := range cfg.orderedParams() { + writeParam(param, cfg.Params[param]) } return cmdSet.String() diff --git a/dsn.go b/dsn.go index 980c9c71b..c122382d5 100644 --- a/dsn.go +++ b/dsn.go @@ -44,7 +44,7 @@ type Config struct { Net string // Network (e.g. "tcp", "tcp6", "unix". default: "tcp") Addr string // Address (default: "127.0.0.1:3306" for "tcp" and "/tmp/mysql.sock" for "unix") DBName string // Database name - Params map[string]string // Connection parameters + Params map[string]string // Connection parameters. Use Apply(AddParam(...)) to preserve insertion order. ConnectionAttributes string // Connection Attributes, comma-delimited string of user-defined "key:value" pairs Collation string // Connection collation. When set, this will be set in SET NAMES COLLATE query Loc *time.Location // Location for time.Time values @@ -113,6 +113,27 @@ func (c *Config) Apply(opts ...Option) error { return nil } +// AddParam adds a connection parameter. +// +// Parameters added with AddParam retain their order in [Config.FormatDSN] and +// are applied to the connection in that order. If name already exists, it is +// moved to the end of the parameter order. +func AddParam(name, value string) Option { + return func(cfg *Config) error { + params := cfg.orderedParams() + if i := slices.Index(params, name); i >= 0 { + params = slices.Delete(params, i, i+1) + } + + if cfg.Params == nil { + cfg.Params = make(map[string]string) + } + cfg.Params[name] = value + cfg.paramOrder = append(params, name) + return nil + } +} + // TimeTruncate sets the time duration to truncate time.Time values in // query parameters. func TimeTruncate(d time.Duration) Option { @@ -158,7 +179,7 @@ func (cfg *Config) Clone() *Config { if cp.TLS != nil { cp.TLS = cfg.TLS.Clone() } - if len(cp.Params) > 0 { + if cp.Params != nil { cp.Params = make(map[string]string, len(cfg.Params)) maps.Copy(cp.Params, cfg.Params) } @@ -387,18 +408,37 @@ func (cfg *Config) FormatDSN() string { } // other params - if cfg.Params != nil { - var params []string - for param := range cfg.Params { - params = append(params, param) + for _, param := range cfg.orderedParams() { + writeDSNParam(&buf, &hasParam, param, url.QueryEscape(cfg.Params[param])) + } + + return buf.String() +} + +// orderedParams returns connection parameter names in their recorded order, +// followed by any names added directly to Config.Params in sorted order. +func (cfg *Config) orderedParams() []string { + params := make([]string, 0, len(cfg.Params)) + seen := make(map[string]struct{}, len(cfg.Params)) + for _, param := range cfg.paramOrder { + if _, ok := cfg.Params[param]; !ok { + continue } - sort.Strings(params) - for _, param := range params { - writeDSNParam(&buf, &hasParam, param, url.QueryEscape(cfg.Params[param])) + if _, ok := seen[param]; ok { + continue } + params = append(params, param) + seen[param] = struct{}{} } - return buf.String() + unordered := make([]string, 0, len(cfg.Params)-len(params)) + for param := range cfg.Params { + if _, ok := seen[param]; !ok { + unordered = append(unordered, param) + } + } + sort.Strings(unordered) + return append(params, unordered...) } // ParseDSN parses the DSN string to a Config diff --git a/dsn_test.go b/dsn_test.go index 679f6ffaa..18eadca41 100644 --- a/dsn_test.go +++ b/dsn_test.go @@ -382,6 +382,48 @@ func TestParamsAreSorted(t *testing.T) { } } +func TestFormatDSNPreservesParamOrder(t *testing.T) { + dsn := "/dbname?second=2&first=1" + cfg, err := ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + want := "tcp(127.0.0.1:3306)" + dsn + if got := cfg.FormatDSN(); got != want { + t.Fatalf("FormatDSN() = %q, want %q", got, want) + } +} + +func TestAddParam(t *testing.T) { + cfg := NewConfig() + if err := cfg.Apply( + AddParam("second", "2"), + AddParam("first", "1"), + AddParam("second", "new"), + ); err != nil { + t.Fatal(err) + } + + if got, want := cfg.FormatDSN(), "/?first=1&second=new"; got != want { + t.Fatalf("FormatDSN() = %q, want %q", got, want) + } + if got, want := cfg.setParamsCommand(), "SET first = 1, second = new"; got != want { + t.Fatalf("setParamsCommand() = %q, want %q", got, want) + } +} + +func TestAddParamAfterDirectParams(t *testing.T) { + cfg := NewConfig() + cfg.Params = map[string]string{"second": "2", "first": "1"} + if err := cfg.Apply(AddParam("third", "3")); err != nil { + t.Fatal(err) + } + + if got, want := cfg.FormatDSN(), "/?first=1&second=2&third=3"; got != want { + t.Fatalf("FormatDSN() = %q, want %q", got, want) + } +} + func TestDSNParamOrder(t *testing.T) { dsn := "/?aurora_read_replica_read_committed=1&transaction_isolation=%27READ-COMMITTED%27" cfg, err := ParseDSN(dsn) @@ -405,6 +447,9 @@ func TestDuplicateDSNParamOrder(t *testing.T) { if got := cfg.setParamsCommand(); got != want { t.Fatalf("setParamsCommand() = %q, want %q", got, want) } + if got, want := cfg.FormatDSN(), "tcp(127.0.0.1:3306)/?second=2&first=new"; got != want { + t.Fatalf("FormatDSN() = %q, want %q", got, want) + } } func TestCloneConfig(t *testing.T) {