diff --git a/connection.go b/connection.go new file mode 100644 index 0000000..369ee3e --- /dev/null +++ b/connection.go @@ -0,0 +1,203 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package main + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "net" + "sync" + "time" +) + +// Config holds the configuration for the driver +type Config struct { + InterpolateParams bool +} + +type clientFlag uint32 +type statusFlag uint16 + +type mysqlConn struct { + netConn net.Conn + closed bool + mu sync.Mutex + cfg *Config +} + +func (mc *mysqlConn) Begin() (driver.Tx, error) { + return mc.begin(false) +} + +func (mc *mysqlConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { + mc.mu.Lock() + if mc.closed { + mc.mu.Unlock() + return nil, driver.ErrBadConn + } + mc.mu.Unlock() + + // Check if context is already canceled + if err := ctx.Err(); err != nil { + return nil, err + } + + var level string + switch sql.IsolationLevel(opts.Isolation) { + case sql.LevelDefault: + level = "" + case sql.LevelReadUncommitted: + level = "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED" + case sql.LevelReadCommitted: + level = "SET TRANSACTION ISOLATION LEVEL READ COMMITTED" + case sql.LevelRepeatableRead: + level = "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ" + case sql.LevelSerializable: + level = "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" + default: + return nil, errors.New("invalid isolation level") + } + + if level != "" { + err := mc.exec(ctx, level) + if ctx.Err() != nil { + mc.mu.Lock() + mc.closed = true + if mc.netConn != nil { + mc.netConn.Close() + } + mc.mu.Unlock() + return nil, driver.ErrBadConn + } + if err != nil { + var netErr net.Error + isTimeout := errors.As(err, &netErr) && netErr.Timeout() + if isTimeout || err.Error() == "write timeout" || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + mc.mu.Lock() + mc.closed = true + if mc.netConn != nil { + mc.netConn.Close() + } + mc.mu.Unlock() + return nil, driver.ErrBadConn + } + return nil, err + } + } + + var startTxQuery string + if opts.ReadOnly { + startTxQuery = "START TRANSACTION READ ONLY" + } else { + startTxQuery = "START TRANSACTION" + } + + err := mc.exec(ctx, startTxQuery) + if ctx.Err() != nil { + mc.mu.Lock() + mc.closed = true + if mc.netConn != nil { + mc.netConn.Close() + } + mc.mu.Unlock() + return nil, driver.ErrBadConn + } + if err != nil { + var netErr net.Error + isTimeout := errors.As(err, &netErr) && netErr.Timeout() + if isTimeout || err.Error() == "write timeout" || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + mc.mu.Lock() + mc.closed = true + if mc.netConn != nil { + mc.netConn.Close() + } + mc.mu.Unlock() + return nil, driver.ErrBadConn + } + return nil, err + } + + return &mysqlTx{mc: mc}, nil +} + +func (mc *mysqlConn) begin(readOnly bool) (driver.Tx, error) { + mc.mu.Lock() + if mc.closed { + mc.mu.Unlock() + return nil, driver.ErrBadConn + } + mc.mu.Unlock() + + var query string + if readOnly { + query = "START TRANSACTION READ ONLY" + } else { + query = "START TRANSACTION" + } + + err := mc.exec(context.Background(), query) + if err != nil { + return nil, err + } + return &mysqlTx{mc: mc}, nil +} + +func (mc *mysqlConn) exec(ctx context.Context, query string) error { + mc.mu.Lock() + closed := mc.closed + netConn := mc.netConn + mc.mu.Unlock() + + if closed || netConn == nil { + return driver.ErrBadConn + } + + // Make the net write operation context-aware using SetWriteDeadline + if deadline, ok := ctx.Deadline(); ok { + netConn.SetWriteDeadline(deadline) + } else if ctx.Done() != nil { + // Context has cancellation but no deadline (cancel-only). + // Spawn a watcher goroutine to close the net connection if context is canceled during Write. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + mc.mu.Lock() + if mc.netConn != nil { + mc.netConn.Close() + } + mc.mu.Unlock() + case <-done: + } + }() + } else { + netConn.SetWriteDeadline(time.Time{}) + } + + _, err := netConn.Write([]byte(query)) + if err != nil { + return err + } + return nil +} + +type mysqlTx struct { + mc *mysqlConn +} + +func (tx *mysqlTx) Commit() error { + return nil +} + +func (tx *mysqlTx) Rollback() error { + return nil +} diff --git a/connection_test.go b/connection_test.go new file mode 100644 index 0000000..7bf0b61 --- /dev/null +++ b/connection_test.go @@ -0,0 +1,316 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2012 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package main + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "net" + "sync" + "testing" + "time" +) + +// mockNetConn simulates a network connection with controllable write delay and deadline support +type mockNetConn struct { + net.Conn + writeDelay time.Duration + closed bool + writeDeadline time.Time + mu sync.Mutex +} + +func (m *mockNetConn) Write(b []byte) (int, error) { + m.mu.Lock() + closed := m.closed + deadline := m.writeDeadline + m.mu.Unlock() + + if closed { + return 0, errors.New("connection closed") + } + + if m.writeDelay > 0 { + if !deadline.IsZero() { + select { + case <-time.After(m.writeDelay): + case <-time.After(time.Until(deadline)): + m.mu.Lock() + m.closed = true + m.mu.Unlock() + return 0, errors.New("write timeout") + } + } else { + time.Sleep(m.writeDelay) + } + } + return len(b), nil +} + +func (m *mockNetConn) Close() error { + m.mu.Lock() + m.closed = true + m.mu.Unlock() + return nil +} + +func (m *mockNetConn) SetWriteDeadline(t time.Time) error { + m.mu.Lock() + m.writeDeadline = t + // If deadline is in the past, simulate immediate timeout + if !t.IsZero() && t.Before(time.Now()) { + m.closed = true + } + m.mu.Unlock() + return nil +} + +func (m *mockNetConn) Read(b []byte) (int, error) { return 0, nil } +func (m *mockNetConn) LocalAddr() net.Addr { return nil } +func (m *mockNetConn) RemoteAddr() net.Addr { return nil } +func (m *mockNetConn) SetDeadline(t time.Time) error { return nil } +func (m *mockNetConn) SetReadDeadline(t time.Time) error { return nil } + +func TestBeginTxContextCancellation(t *testing.T) { + mockNet := &mockNetConn{ + writeDelay: 100 * time.Millisecond, + } + + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := mc.BeginTx(ctx, driver.TxOptions{}) + if err == nil { + t.Fatal("expected error from BeginTx with context timeout") + } + + if !errors.Is(err, driver.ErrBadConn) { + t.Errorf("expected driver.ErrBadConn, got %v", err) + } + + if !mc.closed { + t.Error("expected connection to be marked closed") + } + + mockNet.mu.Lock() + netClosed := mockNet.closed + mockNet.mu.Unlock() + if !netClosed { + t.Error("expected network connection to be closed") + } +} + +func TestBeginTxContextCancellationDuringIsolationSetting(t *testing.T) { + mockNet := &mockNetConn{ + writeDelay: 100 * time.Millisecond, + } + + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := mc.BeginTx(ctx, driver.TxOptions{ + Isolation: driver.IsolationLevel(sql.LevelSerializable), + }) + if err == nil { + t.Fatal("expected error from BeginTx with context timeout during isolation setting") + } + + if !errors.Is(err, driver.ErrBadConn) { + t.Errorf("expected driver.ErrBadConn, got %v", err) + } + + if !mc.closed { + t.Error("expected connection to be marked closed") + } + + mockNet.mu.Lock() + netClosed := mockNet.closed + mockNet.mu.Unlock() + if !netClosed { + t.Error("expected network connection to be closed") + } +} + +func TestBeginTxSuccess(t *testing.T) { + mockNet := &mockNetConn{ + writeDelay: 5 * time.Millisecond, + } + + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + tx, err := mc.BeginTx(ctx, driver.TxOptions{}) + if err != nil { + t.Fatalf("expected successful BeginTx, got error: %v", err) + } + + if tx == nil { + t.Fatal("expected non-nil transaction") + } + + if mc.closed { + t.Error("connection should not be closed on success") + } + + mockNet.mu.Lock() + netClosed := mockNet.closed + mockNet.mu.Unlock() + if netClosed { + t.Error("network connection should not be closed on success") + } +} + +func TestBeginTxAlreadyClosedConnection(t *testing.T) { + mc := &mysqlConn{ + netConn: &mockNetConn{}, + cfg: &Config{}, + closed: true, + } + + ctx := context.Background() + _, err := mc.BeginTx(ctx, driver.TxOptions{}) + + if !errors.Is(err, driver.ErrBadConn) { + t.Errorf("expected driver.ErrBadConn for already closed connection, got %v", err) + } +} + +func TestBeginTxReadOnly(t *testing.T) { + mockNet := &mockNetConn{} + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + ctx := context.Background() + tx, err := mc.BeginTx(ctx, driver.TxOptions{ReadOnly: true}) + if err != nil { + t.Fatalf("expected successful BeginTx with ReadOnly: true, got error: %v", err) + } + + if tx == nil { + t.Fatal("expected non-nil transaction") + } +} + +func TestBeginTxInvalidIsolation(t *testing.T) { + mockNet := &mockNetConn{} + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + ctx := context.Background() + _, err := mc.BeginTx(ctx, driver.TxOptions{Isolation: driver.IsolationLevel(999)}) + if err == nil { + t.Fatal("expected error for invalid isolation level") + } +} + +func TestBeginTxNetworkErrorWithoutCancellation(t *testing.T) { + // Simulate connection already closed at network layer to trigger standard write error + mockNet := &mockNetConn{closed: true} + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + ctx := context.Background() + _, err := mc.BeginTx(ctx, driver.TxOptions{}) + if err == nil { + t.Fatal("expected network write error") + } + + // Should not mask simple network error as ErrBadConn unless context is canceled + if errors.Is(err, driver.ErrBadConn) { + t.Error("should not return driver.ErrBadConn for simple network write error without context cancellation") + } +} + +func TestBeginLegacy(t *testing.T) { + mockNet := &mockNetConn{} + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + tx, err := mc.Begin() + if err != nil { + t.Fatalf("expected successful legacy Begin, got error: %v", err) + } + + if tx == nil { + t.Fatal("expected non-nil transaction") + } +} + +func TestBeginTxCancelOnlyContextDuringWrite(t *testing.T) { + mockNet := &mockNetConn{ + writeDelay: 100 * time.Millisecond, + } + + mc := &mysqlConn{ + netConn: mockNet, + cfg: &Config{}, + closed: false, + } + + // Create a context with cancellation but no deadline (cancel-only) + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel the context after 50ms (during the mock query write delay) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + _, err := mc.BeginTx(ctx, driver.TxOptions{}) + if err == nil { + t.Fatal("expected error from BeginTx with cancel-only context") + } + + if !errors.Is(err, driver.ErrBadConn) { + t.Errorf("expected driver.ErrBadConn, got %v", err) + } + + if !mc.closed { + t.Error("expected connection to be marked closed after context cancellation") + } + + mockNet.mu.Lock() + netClosed := mockNet.closed + mockNet.mu.Unlock() + if !netClosed { + t.Error("expected network connection to be closed after context cancellation") + } +}