diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4db13b --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Created by .ignore support plugin (hsz.mobi) +### Go template +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..25c82eb --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +###Go-Mocket + +Go-Mocket is library inspired by [DATA-DOG/go-sqlmock](https://github.com/DATA-DOG/go-sqlmock) +As inspiration library it is implementation of [sql/driver](https://godoc.org/database/sql/driver) interface but at the same time follows different approaches and has only similar API. +This library helps to mock any DB connection also with [jinzhu/gorm](https://github.com/jinzhu/gorm) and it was main goal to create it + +List of features in the library: + +* Mock `INSERT`, `UPDATE`, `SELECT`, `DELETE` +* Support of transactions +* 2 API's to use - `chaining` and via specifying whole mock object +* Matching by prepared statements arguments +* You will not require to change anything inside you code to start using this library +* Ability to trigger exceptions +* Attach callbacks to mocked response to add additional check or modify response + +**NOTE** Please be aware that driver catches SQL without DB specifics. Generating of queries is done by *sql* package + +####Install +``` +go get github.com/selvatico/go-mocket +``` + +#### Usage +There are two possible ways to use `mocket`: + +* Chaining API +* Specifying `FakeResponse` object with all fields manually. Could be useful for + +##### Enabling driver + +Somewhere in you code to setup a tests +``` +import ( + "database/sql" + mocket "github.com/selvatico/go-mocket" + "github.com/jinzhu/gorm" +) + +func SetupTests() { + sql.Register("fake_test", mocket.FakeDriver{}) + // GORM + db, err := gorm.Open("fake_test", "connection_string") // Could be any connection string + app.DB = db + + // Regular sql package usage + db, err := sql.Open(driver, source) +} +``` +Now if use singleton instance of DB it will use everywhere mocked connection. + +##### Chain usage +###### Example of mocking by pattern + +```go +import mocket "github.com/selvatico/go-mocket" +import "net/http/httptest" + +func TestHandler(t *testing.T) { + request := httptest.NewRequest("POST", "/application", postBody) + recorder := httptest.NewRecorder() + + GlobalMock := mocket.Catcher + GlobalMock.Logging = true // log mocket behavior + + commonReply := []map[string]interface{}{{"id": "2", "field": "value"}} + // Mock only by query pattern + GlobalMock.NewMock().WithQuery(`"campaigns".name IS NULL AND (("uuid" = test_uuid))`).WithReply(commonReply) + Post(recorder, request) // call handler + + r := recorder.Result() + body, _ := ioutil.ReadAll(r.Body) + + // some assertion about results + //... + +} + +``` + + +Documentation in progress.... + + diff --git a/conn.go b/conn.go new file mode 100644 index 0000000..10d8b72 --- /dev/null +++ b/conn.go @@ -0,0 +1,66 @@ +package go_mocket + +import ( + "context" + "database/sql/driver" + "errors" + "strings" + "sync" +) + +type FakeConn struct { + db *FakeDB + currTx *FakeTx // Transaction pointer + mu sync.Mutex + bad bool +} + +func (c *FakeConn) isBad() bool { + return false +} + +func (c *FakeConn) Begin() (driver.Tx, error) { + if c.isBad() { + return nil, driver.ErrBadConn + } + if c.currTx != nil { + return nil, errors.New("already in a transaction") + } + c.currTx = &FakeTx{c: c} + return c.currTx, nil +} + +func (c *FakeConn) Close() (err error) { + c.db = nil + return nil +} + +func (c *FakeConn) Exec(query string, args []driver.Value) (driver.Result, error) { + panic("ExecContext was not called.") +} + +func (c *FakeConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + return nil, driver.ErrSkip +} + +func (c *FakeConn) Query(query string, args []driver.Value) (driver.Rows, error) { + panic("QueryContext was not called.") +} + +// We do +func (c *FakeConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + return nil, driver.ErrSkip +} + +// Should not be called +func (c *FakeConn) Prepare(query string) (driver.Stmt, error) { + panic("use Prepare") +} + +func (c *FakeConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { + var firstStmt = &FakeStmt{q: query, connection: c} // Create statement + firstStmt.placeholders = len(strings.Split(query, "?")) - 1 // Checking how many placeholders do we have + queryParts := strings.Split(query, " ") // By First statement define the query type + firstStmt.command = strings.ToUpper(queryParts[0]) + return firstStmt, nil +} diff --git a/driver.go b/driver.go new file mode 100644 index 0000000..f3510f8 --- /dev/null +++ b/driver.go @@ -0,0 +1,59 @@ +package go_mocket + +import ( + "database/sql/driver" + "log" + "sync" +) + +var _ = log.Printf + +type FakeDriver struct { + mu sync.Mutex // guards 3 following fields + openCount int // conn opens + closeCount int // conn closes + waitCh chan struct{} + waitingCh chan struct{} + dbs map[string]*FakeDB +} + +type FakeDB struct { + name string + mu sync.Mutex + tables map[string]*table + badConn bool +} + +type table struct { + mu sync.Mutex + colname []string + coltype []string + rows []*row +} + +func (t *table) columnIndex(name string) int { + for n, name := range t.colname { + if name == name { + return n + } + } + return -1 +} + +func (d FakeDriver) Open(database string) (driver.Conn, error) { + return &FakeConn{db: d.getDB(database)}, nil +} + +func (d *FakeDriver) getDB(name string) *FakeDB { + d.mu.Lock() + defer d.mu.Unlock() + if d.dbs == nil { + d.dbs = make(map[string]*FakeDB) + } + db, ok := d.dbs[name] + if !ok { + db = &FakeDB{name: name} + d.dbs[name] = db + } + return db +} \ No newline at end of file diff --git a/response.go b/response.go new file mode 100644 index 0000000..9e8eeb7 --- /dev/null +++ b/response.go @@ -0,0 +1,165 @@ +package go_mocket + +import ( + "database/sql/driver" + "fmt" + "log" + "reflect" + "strings" +) + +var Catcher *MockCatcher + +type MockCatcher struct { + Mocks []*FakeResponse + Logging bool + PanicOnEmptyResponse bool // If not response matches - do we need to panic? +} + +func (this *MockCatcher) Attach(fr []*FakeResponse) { + this.Mocks = append(this.Mocks, fr...) +} + +// Find suitable response by provided +func (this *MockCatcher) FindResponse(query string, args []driver.NamedValue) *FakeResponse { + if this.Logging { + log.Printf("mock_catcher: check query: %s", query) + } + + for _, resp := range this.Mocks { + if resp.IsMatch(query, args) { + resp.MarkAsTriggered() + return resp + } + } + + if this.PanicOnEmptyResponse { + panic(fmt.Sprintf("No responses matches query %s ", query)) + } + + // Let's have always dummy version of response + return &FakeResponse{ + Response: make([]map[string]interface{}, 0), + Exceptions: &Exceptions{}, + } +} + +// Create new FakeResponse and return for chains of attachments +func (this *MockCatcher) NewMock() *FakeResponse { + fr := &FakeResponse{Exceptions: &Exceptions{}, Response: make([]map[string]interface{}, 0)} + this.Mocks = append(this.Mocks, fr) + return fr +} + +// Remove all Mocks to start process again +func (this *MockCatcher) Reset() *MockCatcher { + this.Mocks = make([]*FakeResponse, 0) + return this +} + +// Possible exceptions during query executions +type Exceptions struct { + HookQueryBadConnection func() bool + HookExecBadConnection func() bool +} + +// Represents mock of response with holding all required values to return mocked response +type FakeResponse struct { + Pattern string // SQL query pattern to match with + Args []interface{} // List args to be matched with + Response []map[string]interface{} // Array of rows to be parsed as result + Once bool // To trigger only once + Triggered bool // If it was triggered at least once + Callback func(string, []driver.NamedValue) // Callback to execute when response triggered + RowsAffected int64 // Defines affected rows count + LastInsertId int64 // ID to be returned for INSERT queries + *Exceptions +} + +// Return true either when nothing to compare or deep equal check passed +func (fr *FakeResponse) isArgsMatch(args []driver.NamedValue) bool { + arguments := make([]interface{}, len(args)) + if len(args) > 0 { + for index, arg := range args { + arguments[index] = arg.Value + } + } + return fr.Args == nil || reflect.DeepEqual(fr.Args, arguments) +} + +func (fr *FakeResponse) isQueryMatch(query string) bool { + return fr.Pattern == "" || strings.Contains(query, fr.Pattern) +} + +func (fr *FakeResponse) IsMatch(query string, args []driver.NamedValue) bool { + if fr.Once && fr.Triggered { + return false + } + return fr.isQueryMatch(query) && fr.isArgsMatch(args) +} + +func (fr *FakeResponse) MarkAsTriggered() { + fr.Triggered = true +} + +// For chaining init +func (fr *FakeResponse) WithQuery(query string) *FakeResponse { + fr.Pattern = query + return fr +} + +// Attach Args check for prepared statements +func (fr *FakeResponse) WithArgs(vars ...interface{}) *FakeResponse { + if len(vars) > 0 { + fr.Args = make([]interface{}, len(vars)) + for index, v := range vars { + fr.Args[index] = v + } + } + return fr +} + +// Methods to chain and assign some parts of response + +func (fr *FakeResponse) WithReply(response []map[string]interface{}) *FakeResponse { + fr.Response = response + return fr +} + +func (fr *FakeResponse) OneTime() *FakeResponse { + fr.Once = true + return fr +} + +func (fr *FakeResponse) WithExecException() *FakeResponse { + fr.Exceptions.HookExecBadConnection = func() bool { + return true + } + return fr +} + +func (fr *FakeResponse) WithQueryException() *FakeResponse { + fr.Exceptions.HookQueryBadConnection = func() bool { + return true + } + return fr +} + +func (fr *FakeResponse) WithCallback(f func(string, []driver.NamedValue)) *FakeResponse { + fr.Callback = f + return fr +} + +func (fr *FakeResponse) WithRowsNum(num int64) *FakeResponse { + fr.RowsAffected = num + return fr +} + +func (fr *FakeResponse) WithId(id int64) *FakeResponse { + fr.LastInsertId = id + return fr +} + +func init() { + Catcher = &MockCatcher{} +} diff --git a/result.go b/result.go new file mode 100644 index 0000000..f99fc1e --- /dev/null +++ b/result.go @@ -0,0 +1,22 @@ +package go_mocket + +import ( + "database/sql/driver" +) + +type FakeResult struct { + insertID int64 + rowsAffected int64 +} + +func NewFakeResult(insertId int64, rowsAffected int64) driver.Result { + return &FakeResult{insertId, rowsAffected} +} + +func (fr *FakeResult) LastInsertId() (int64, error) { + return fr.insertID, nil +} + +func (fr *FakeResult) RowsAffected() (int64, error) { + return fr.rowsAffected, nil +} diff --git a/rows.go b/rows.go new file mode 100644 index 0000000..cbd1fe0 --- /dev/null +++ b/rows.go @@ -0,0 +1,115 @@ +package go_mocket + +import ( + "database/sql" + "database/sql/driver" + "errors" + "io" + "reflect" + "time" +) + +type RowsCursor struct { + cols []string + colType [][]string + posSet int + posRow int + rows [][]*row + closed bool + + // errPos and err are for making Next return early with error. + errPos int + err error + + bytesClone map[*byte][]byte +} + +type row struct { + cols []interface{} // must be same size as its table colname + coltype +} + +func (rc *RowsCursor) Close() error { + if !rc.closed { + for _, bs := range rc.bytesClone { + bs[0] = 255 // first byte corrupted + } + } + rc.closed = true + return nil +} + +func (rc *RowsCursor) Columns() []string { + return rc.cols +} + +func (rc *RowsCursor) ColumnTypeScanType(index int) reflect.Type { + return colTypeToReflectType(rc.colType[rc.posSet][index]) +} + +func (rc *RowsCursor) Next(accumulator []driver.Value) error { + if rc.closed { + return errors.New("fake_db_driver: cursor is closed") + } + rc.posRow++ + if rc.posRow == rc.errPos { + return rc.err + } + if rc.posRow >= len(rc.rows[rc.posSet]) { + return io.EOF // per interface spec + } + for i, v := range rc.rows[rc.posSet][rc.posRow].cols { + accumulator[i] = v + if bs, ok := v.([]byte); ok { + if rc.bytesClone == nil { + rc.bytesClone = make(map[*byte][]byte) + } + clone, ok := rc.bytesClone[&bs[0]] + if !ok { + clone = make([]byte, len(bs)) + copy(clone, bs) + rc.bytesClone[&bs[0]] = clone + } + accumulator[i] = clone + } + } + return nil +} + +func (rc *RowsCursor) HasNextResultSet() bool { + return rc.posSet < len(rc.rows)-1 +} + +func (rc *RowsCursor) NextResultSet() error { + if rc.HasNextResultSet() { + rc.posSet++ + rc.posRow = -1 + return nil + } + return io.EOF // Per interface spec. +} + +func colTypeToReflectType(typ string) reflect.Type { + switch typ { + case "bool": + return reflect.TypeOf(false) + case "nullbool": + return reflect.TypeOf(sql.NullBool{}) + case "int32": + return reflect.TypeOf(int32(0)) + case "string": + return reflect.TypeOf("") + case "nullstring": + return reflect.TypeOf(sql.NullString{}) + case "int64": + return reflect.TypeOf(int64(0)) + case "nullint64": + return reflect.TypeOf(sql.NullInt64{}) + case "float64": + return reflect.TypeOf(float64(0)) + case "nullfloat64": + return reflect.TypeOf(sql.NullFloat64{}) + case "datetime": + return reflect.TypeOf(time.Time{}) + } + panic("invalid fakedb column type of " + typ) +} diff --git a/stmt.go b/stmt.go new file mode 100644 index 0000000..8fb49af --- /dev/null +++ b/stmt.go @@ -0,0 +1,175 @@ +package go_mocket + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "math/rand" + "strings" +) + +type FakeStmt struct { + connection *FakeConn + q string // just for debugging SQL query generated by sql package + command string // String name of the command SELECT etc, taken as first word in the query + next *FakeStmt // used for returning multiple results. + closed bool // If connection closed already + colName []string //Names of columns in response + colType []string // Not used for now + placeholders int // Amount of passed args +} + +func (s *FakeStmt) ColumnConverter(idx int) driver.ValueConverter { + return driver.DefaultParameterConverter +} + +func (s *FakeStmt) Close() error { + // No connection added + if s.connection == nil { + panic("nil conn in FakeStmt.Close") + } + if s.connection.db == nil { + panic("in FakeStmt.Close, conn's db is nil (already closed)") + } + if !s.closed { + s.closed = true + } + if s.next != nil { + s.next.Close() + } + return nil +} + +var errClosed = errors.New("fake_db_driver: statement has been closed") + +func (smt *FakeStmt) Exec(args []driver.Value) (driver.Result, error) { + panic("Using ExecContext") +} + +func (smt *FakeStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) { + if smt.closed { + return nil, errClosed + } + + fResp := Catcher.FindResponse(smt.q, args) + + // To emulate any exception during query which returns rows + if fResp.Exceptions != nil && fResp.Exceptions.HookExecBadConnection != nil && fResp.Exceptions.HookExecBadConnection() { + return nil, driver.ErrBadConn + } + + if fResp.Callback != nil { + fResp.Callback(smt.q, args) + } + + switch smt.command { + case "INSERT": + id := fResp.LastInsertId + if id == 0 { + id = rand.Int63() + } + res := NewFakeResult(id, 1) + return res, nil + case "UPDATE": + return driver.RowsAffected(fResp.RowsAffected), nil + } + return nil, fmt.Errorf("unimplemented statement Exec command type of %q", smt.command) +} + +func (s *FakeStmt) Query(args []driver.Value) (driver.Rows, error) { + panic("Use QueryContext") +} + +func (smt *FakeStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) { + + if smt.closed { + return nil, errClosed + } + + if len(args) > 0 { + // Replace all "?" to "%v" and replace them with the values after + for i := 0; i < len(args); i++ { + smt.q = strings.Replace(smt.q, "?", "%v", 1) + smt.q = fmt.Sprintf(smt.q, args[i].Value) + } + } + + fResp := Catcher.FindResponse(smt.q, args) + + if fResp.Exceptions != nil && fResp.Exceptions.HookQueryBadConnection != nil && fResp.Exceptions.HookQueryBadConnection() { + return nil, driver.ErrBadConn + } + + resultRows := make([][]*row, 0, 1) + columnNames := make([]string, 0, 1) + columnTypes := make([][]string, 0, 1) + rows := []*row{} + + // Check if we have such query in the map + colIndexes := make(map[string]int) + + // Collecting column names from first record + if len(fResp.Response) > 0 { + for colName, _ := range fResp.Response[0] { + columnNames = append(columnNames, colName) + colIndexes[colName] = len(columnNames) - 1 + } + } + + // Extracting values from result according columns + for _, record := range fResp.Response { + oneRow := &row{cols: make([]interface{}, len(columnNames))} + for _, col := range columnNames { + oneRow.cols[colIndexes[col]] = []byte(record[col].(string)) + } + rows = append(rows, oneRow) + } + resultRows = append(resultRows, rows) + + cursor := &RowsCursor{ + posRow: -1, + rows: resultRows, + cols: columnNames, + colType: columnTypes, // TODO: implement support of that + errPos: -1, + closed: false, + } + + if fResp.Callback != nil { + fResp.Callback(smt.q, args) + } + + return cursor, nil +} + +// Returns number of args passed to query +func (s *FakeStmt) NumInput() int { + return s.placeholders +} + +type FakeTx struct { + c *FakeConn +} + +// hook to simulate broken connections +var HookBadCommit func() bool + +func (tx *FakeTx) Commit() error { + tx.c.currTx = nil + if HookBadCommit != nil && HookBadCommit() { + return driver.ErrBadConn + } + return nil +} + +// hook to simulate broken connections +var HookBadRollback func() bool + +func (tx *FakeTx) Rollback() error { + tx.c.currTx = nil + if HookBadRollback != nil && HookBadRollback() { + return driver.ErrBadConn + } + return nil +}