diff --git a/README.md b/README.md index e63751eee..abef7e808 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,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 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 5a25c878a..daff945ec 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,11 @@ func (mc *mysqlConn) handleParams() (err error) { cmdSet.WriteString(val) } - if cmdSet.Len() > 0 { - err = mc.exec(cmdSet.String()) + for _, param := range cfg.orderedParams() { + writeParam(param, cfg.Params[param]) } - return + return cmdSet.String() } // markBadConn replaces errBadConnNoWrite with driver.ErrBadConn. diff --git a/dsn.go b/dsn.go index 74dc6d901..d43367eaf 100644 --- a/dsn.go +++ b/dsn.go @@ -19,6 +19,7 @@ import ( "math/big" "net" "net/url" + "slices" "sort" "strconv" "strings" @@ -43,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 @@ -80,6 +81,7 @@ type Config struct { tinyInt1IsBool bool // Treat signed TINYINT(1) as boolean 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 @@ -113,6 +115,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 { @@ -166,10 +189,11 @@ 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) } + cp.paramOrder = slices.Clone(cfg.paramOrder) if cfg.pubKey != nil { cp.pubKey = &rsa.PublicKey{ N: new(big.Int).Set(cfg.pubKey.N), @@ -398,18 +422,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 @@ -706,14 +749,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 131f8a981..67cd26e7e 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: 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, tinyInt1IsBool: true}, @@ -382,6 +382,76 @@ 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) + 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) + } + 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) { RegisterServerPubKey("testKey", testPubKeyRSA) defer DeregisterServerPubKey("testKey") @@ -417,6 +487,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") }