From e49aa5f6200a01e5a8a0a25fbc1d9a25d3f1fed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Garcia?= Date: Fri, 1 Jan 2021 17:01:57 -0300 Subject: [PATCH] Add example tests to `examples/testing` --- Makefile | 10 + README.md | 20 +- examples/crud/crud.go | 109 +++++++++ examples/testing/example_service.go | 105 +++++++++ examples/testing/example_service_test.go | 284 +++++++++++++++++++++++ examples/testing/mocks.go | 164 +++++++++++++ go.mod | 4 + go.sum | 20 ++ kiss_orm.go | 240 ++----------------- kiss_orm_test.go | 139 +++-------- mocks.go | 43 ++++ nullable/nullable.go | 75 ++++++ structs.go | 267 +++++++++++++++++++++ structs_test.go | 211 +++++++++++++++++ 14 files changed, 1363 insertions(+), 328 deletions(-) create mode 100644 examples/crud/crud.go create mode 100644 examples/testing/example_service.go create mode 100644 examples/testing/example_service_test.go create mode 100644 examples/testing/mocks.go create mode 100644 mocks.go create mode 100644 structs.go create mode 100644 structs_test.go diff --git a/Makefile b/Makefile index cba4e80..c16cf7a 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,18 @@ lint: setup @go vet $(path) $(args) @echo "Golint & Go Vet found no problems on your code!" +mock: setup + mockgen -package=exampleservice -source=contracts.go -destination=examples/testing/mocks.go + setup: .make.setup .make.setup: go get github.com/kyoh86/richgo go get golang.org/x/lint + @# (Gomock is used on examples/testing) + go get github.com/golang/mock/gomock + go install github.com/golang/mock/mockgen touch .make.setup + +# Running examples: +exampleservice: + $(GOPATH)/bin/richgo test ./examples/testing/... diff --git a/README.md b/README.md index 8c44f17..702c0a6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ The goals were: - It should be easy to mock and test (very easy) - It should be above all readable. +**Supported Drivers:** + +Currently we only support 2 Drivers: + +- `"postgres"` +- `"sqlite3"` + ### Usage examples This example is also available [here][./examples/crud/crud.go] @@ -43,7 +50,7 @@ type PartialUpdateUser struct { func main() { ctx := context.Background() - db, err := kissorm.NewClient("sqlite3", "/tmp/hello.sqlite", 1, "users") + db, err := kissorm.New("sqlite3", "/tmp/hello.sqlite", 1, "users") if err != nil { panic(err.Error()) } @@ -149,3 +156,14 @@ type ORMProvider interface { ``` You might notice we are lacking an abstraction for transactions, but it is on our TODO list. + +### TODO List + +- Add support for transactions +- Improve error messages +- Allow the ID field to have a different name +- Allow database replicas for reading +- Fix a bug that is causing "database locked" errors when some the tests fail +- Implement a method of saving and struct fields as JSON on the database (an retrieving them) +- Double check if all reflection is safe on the Insert() function +- Make sure SELECT * works even if not all fields are present diff --git a/examples/crud/crud.go b/examples/crud/crud.go new file mode 100644 index 0000000..0d90e2a --- /dev/null +++ b/examples/crud/crud.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "fmt" + + _ "github.com/mattn/go-sqlite3" + "github.com/vingarcia/kissorm" + "github.com/vingarcia/kissorm/nullable" +) + +type User struct { + ID int `kissorm:"id"` + Name string `kissorm:"name"` + Age int `kissorm:"age"` +} + +type PartialUpdateUser struct { + ID int `kissorm:"id"` + Name *string `kissorm:"name"` + Age *int `kissorm:"age"` +} + +func main() { + ctx := context.Background() + db, err := kissorm.New("sqlite3", "/tmp/hello.sqlite", 1, "users") + if err != nil { + panic(err.Error()) + } + + err = db.Exec(ctx, `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + age INTEGER, + name TEXT + )`) + if err != nil { + panic(err.Error()) + } + + var alison = User{ + Name: "Alison", + Age: 22, + } + err = db.Insert(ctx, &alison) + if err != nil { + panic(err.Error()) + } + fmt.Println("Alison ID:", alison.ID) + + // Inserting inline: + err = db.Insert(ctx, &User{ + Name: "Cristina", + Age: 27, + }) + if err != nil { + panic(err.Error()) + } + + // Deleting Alison: + err = db.Delete(ctx, alison.ID) + if err != nil { + panic(err.Error()) + } + + // Retrieving Cristina: + var cris User + err = db.QueryOne(ctx, &cris, "SELECT * FROM users WHERE name = ? ORDER BY id", "Cristina") + if err != nil { + panic(err.Error()) + } + fmt.Printf("Cristina: %#v\n", cris) + + // Updating all fields from Cristina: + cris.Name = "Cris" + err = db.Update(ctx, cris) + + // Changing the age of Cristina but not touching any other fields: + + // Partial update technique 1: + err = db.Update(ctx, struct { + ID int `kissorm:"id"` + Age int `kissorm:"age"` + }{ID: cris.ID, Age: 28}) + if err != nil { + panic(err.Error()) + } + + // Partial update technique 2: + err = db.Update(ctx, PartialUpdateUser{ + ID: cris.ID, + Age: nullable.Int(28), + }) + if err != nil { + panic(err.Error()) + } + + // Listing first 10 users from the database + // (each time you run this example a new Cristina is created) + // + // Note: Using this function it is recommended to set a LIMIT, since + // not doing so can load too many users on your computer's memory or + // cause an Out Of Memory Kill. + var users []User + err = db.Query(ctx, &users, "SELECT * FROM users LIMIT 10") + if err != nil { + panic(err.Error()) + } + fmt.Printf("Users: %#v\n", users) +} diff --git a/examples/testing/example_service.go b/examples/testing/example_service.go new file mode 100644 index 0000000..ae8ff65 --- /dev/null +++ b/examples/testing/example_service.go @@ -0,0 +1,105 @@ +package exampleservice + +import ( + "context" + "time" + + "github.com/vingarcia/kissorm" + "github.com/vingarcia/kissorm/nullable" +) + +// Service ... +type Service struct { + usersTable kissorm.ORMProvider + streamChunkSize int +} + +// UserEntity represents a domain user, +// the pointer fields represent optional fields that +// might not be present in some requests. +// +// Its recommended that this struct contains +// one field for each database column, +// so you can write generic queries like `SELECT * FROM users`. +// +// If this is not the case, it might be a good idea +// to create a DTO struct to receive select queries. +type UserEntity struct { + ID int `kissorm:"id"` + Name *string `kissorm:"name"` + Age *int `kissorm:"age"` + Score *int `kissorm:"score"` + LastPayment time.Time `kissorm:"last_payment"` +} + +// NewUserService ... +func NewUserService(usersTable kissorm.ORMProvider) Service { + return Service{ + usersTable: usersTable, + streamChunkSize: 100, + } +} + +// CreateUser ... +func (s Service) CreateUser(ctx context.Context, u UserEntity) error { + return s.usersTable.Insert(ctx, &u) +} + +// UpdateUserScore update the user score adding scoreChange with the current +// user score. Defaults to 0 if not set. +func (s Service) UpdateUserScore(ctx context.Context, uID int, scoreChange int) error { + var scoreRow struct { + Score int `kissorm:"score"` + } + err := s.usersTable.QueryOne(ctx, &scoreRow, "SELECT score FROM users WHERE id = ?", uID) + if err != nil { + return err + } + + return s.usersTable.Update(ctx, &UserEntity{ + ID: uID, + Score: nullable.Int(scoreRow.Score + scoreChange), + }) +} + +// ListUsers returns a page of users +func (s Service) ListUsers(ctx context.Context, offset, limit int) (total int, users []UserEntity, err error) { + var countRow struct { + Count int `kissorm:"count"` + } + err = s.usersTable.QueryOne(ctx, &countRow, "SELECT count(*) as count FROM users") + if err != nil { + return 0, nil, err + } + + return countRow.Count, users, s.usersTable.Query(ctx, &users, "SELECT * FROM users OFFSET ? LIMIT ?", offset, limit) +} + +// StreamAllUsers sends all users from the database to an external client +// +// Note: This method is unusual, but so are the use-cases for the QueryChunks function. +// In most cases you should just use the Query or QueryOne functions and use the QueryChunks +// function only when the ammount of data loaded might exceed the available memory and/or +// when you can't put an upper limit on the number of values returned. +func (s Service) StreamAllUsers(ctx context.Context, sendUser func(u UserEntity) error) error { + return s.usersTable.QueryChunks(ctx, kissorm.ChunkParser{ + Query: "SELECT * FROM users", + Params: []interface{}{}, + ChunkSize: s.streamChunkSize, + ForEachChunk: func(users []UserEntity) error { + for _, user := range users { + err := sendUser(user) + if err != nil { + // This will abort the QueryChunks loop and return this error + return err + } + } + return nil + }, + }) +} + +// DeleteUser deletes a user by its ID +func (s Service) DeleteUser(ctx context.Context, uID int) error { + return s.usersTable.Delete(ctx, uID) +} diff --git a/examples/testing/example_service_test.go b/examples/testing/example_service_test.go new file mode 100644 index 0000000..533a5da --- /dev/null +++ b/examples/testing/example_service_test.go @@ -0,0 +1,284 @@ +package exampleservice + +import ( + "context" + "testing" + + gomock "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "github.com/tj/assert" + "github.com/vingarcia/kissorm" + "github.com/vingarcia/kissorm/nullable" +) + +func TestCreateUser(t *testing.T) { + t.Run("should call kissorm.Insert correctly", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + usersTableMock := NewMockORMProvider(controller) + + s := Service{ + usersTable: usersTableMock, + streamChunkSize: 100, + } + + var users []interface{} + usersTableMock.EXPECT().Insert(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, records ...interface{}) error { + users = append(users, records...) + return nil + }) + + user := UserEntity{Name: nullable.String("TestUser")} + + err := s.CreateUser(context.TODO(), user) + assert.Nil(t, err) + assert.Equal(t, 1, len(users)) + assert.Equal(t, &user, users[0]) + }) + + t.Run("another way of testing input structs", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + usersTableMock := NewMockORMProvider(controller) + + s := Service{ + usersTable: usersTableMock, + streamChunkSize: 100, + } + + var users []map[string]interface{} + usersTableMock.EXPECT().Insert(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, records ...interface{}) error { + for _, record := range records { + // The StructToMap function will convert a struct with `kissorm` tags + // into a map using the kissorm attr names as keys. + // + // If you are inserting an anonymous struct (not usual) this function + // can make your tests shorter: + uMap, err := kissorm.StructToMap(record) + if err != nil { + return err + } + users = append(users, uMap) + } + return nil + }) + + user := UserEntity{Name: nullable.String("TestUser")} + + err := s.CreateUser(context.TODO(), user) + assert.Nil(t, err) + assert.Equal(t, 1, len(users)) + + assert.Equal(t, "TestUser", users[0]["name"]) + }) +} + +func TestUpdateUserScore(t *testing.T) { + t.Run("should call kissorm.QueryOne() & Update() correctly", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + usersTableMock := NewMockORMProvider(controller) + + s := Service{ + usersTable: usersTableMock, + streamChunkSize: 100, + } + + var users []interface{} + gomock.InOrder( + usersTableMock.EXPECT().QueryOne(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, result interface{}, query string, params ...interface{}) error { + // This function will use reflection to fill the + // struct fields with the values from the map + return kissorm.FillStructWith(result, map[string]interface{}{ + // Use int this map the keys you set on the kissorm tags, e.g. `kissorm:"score"` + // Each of these fields represent the database rows returned + // by the query. + "score": 42, + }) + }), + usersTableMock.EXPECT().Update(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, records ...interface{}) error { + users = append(users, records...) + return nil + }), + ) + + err := s.UpdateUserScore(context.TODO(), 1, -2) + assert.Nil(t, err) + assert.Equal(t, 1, len(users)) + + resultUser := UserEntity{ + ID: 1, + Score: nullable.Int(40), + } + assert.Equal(t, &resultUser, users[0]) + }) +} + +func TestListUsers(t *testing.T) { + t.Run("should call kissorm.QueryOne() & Query() correctly", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + usersTableMock := NewMockORMProvider(controller) + + s := Service{ + usersTable: usersTableMock, + streamChunkSize: 100, + } + + gomock.InOrder( + usersTableMock.EXPECT().QueryOne(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, result interface{}, query string, params ...interface{}) error { + // This function will use reflection to fill the + // struct fields with the values from the map + return kissorm.FillStructWith(result, map[string]interface{}{ + // Use int this map the keys you set on the kissorm tags, e.g. `kissorm:"score"` + // Each of these fields represent the database rows returned + // by the query. + "count": 420, + }) + }), + usersTableMock.EXPECT().Query(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, results interface{}, query string, params ...interface{}) error { + return kissorm.FillSliceWith(results, []map[string]interface{}{ + { + "id": 1, + "name": "fake name", + "age": 42, + }, + { + "id": 2, + "name": "another fake name", + "age": 43, + }, + }) + }), + ) + + total, users, err := s.ListUsers(context.TODO(), 40, 2) + assert.Nil(t, err) + assert.Equal(t, 420, total) + assert.Equal(t, 2, len(users)) + + expectedUsers := []UserEntity{ + { + ID: 1, + Name: nullable.String("fake name"), + Age: nullable.Int(42), + }, + { + ID: 2, + Name: nullable.String("another fake name"), + Age: nullable.Int(43), + }, + } + assert.Equal(t, expectedUsers, users) + }) +} + +func TestStreamAllUsers(t *testing.T) { + t.Run("should call kissorm.QueryChunks correctly", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + usersTableMock := NewMockORMProvider(controller) + + s := Service{ + usersTable: usersTableMock, + streamChunkSize: 2, + } + + usersTableMock.EXPECT().QueryChunks(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, parser kissorm.ChunkParser) error { + fn, ok := parser.ForEachChunk.(func(users []UserEntity) error) + require.True(t, ok) + // Chunk 1: + err := fn([]UserEntity{ + { + ID: 1, + Name: nullable.String("fake name"), + Age: nullable.Int(42), + }, + { + ID: 2, + Name: nullable.String("another fake name"), + Age: nullable.Int(43), + }, + }) + if err != nil { + return err + } + + // Chunk 2: + err = fn([]UserEntity{ + { + ID: 3, + Name: nullable.String("yet another fake name"), + Age: nullable.Int(44), + }, + }) + return err + }) + + var users []UserEntity + err := s.StreamAllUsers(context.TODO(), func(u UserEntity) error { + users = append(users, u) + return nil + }) + + assert.Nil(t, err) + assert.Equal(t, 3, len(users)) + + expectedUsers := []UserEntity{ + { + ID: 1, + Name: nullable.String("fake name"), + Age: nullable.Int(42), + }, + { + ID: 2, + Name: nullable.String("another fake name"), + Age: nullable.Int(43), + }, + { + ID: 3, + Name: nullable.String("yet another fake name"), + Age: nullable.Int(44), + }, + } + assert.Equal(t, expectedUsers, users) + }) +} + +func TestDeleteUser(t *testing.T) { + t.Run("should call kissorm.Delete correctly", func(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + usersTableMock := NewMockORMProvider(controller) + + s := Service{ + usersTable: usersTableMock, + streamChunkSize: 100, + } + + var ids []interface{} + usersTableMock.EXPECT().Delete(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, idArgs ...interface{}) error { + ids = append(ids, idArgs...) + return nil + }) + + err := s.DeleteUser(context.TODO(), 42) + assert.Nil(t, err) + assert.Equal(t, 1, len(ids)) + assert.Equal(t, 42, ids[0]) + }) +} diff --git a/examples/testing/mocks.go b/examples/testing/mocks.go new file mode 100644 index 0000000..af57e07 --- /dev/null +++ b/examples/testing/mocks.go @@ -0,0 +1,164 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: contracts.go + +// Package exampleservice is a generated GoMock package. +package exampleservice + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + kissorm "github.com/vingarcia/kissorm" +) + +// MockORMProvider is a mock of ORMProvider interface. +type MockORMProvider struct { + ctrl *gomock.Controller + recorder *MockORMProviderMockRecorder +} + +// MockORMProviderMockRecorder is the mock recorder for MockORMProvider. +type MockORMProviderMockRecorder struct { + mock *MockORMProvider +} + +// NewMockORMProvider creates a new mock instance. +func NewMockORMProvider(ctrl *gomock.Controller) *MockORMProvider { + mock := &MockORMProvider{ctrl: ctrl} + mock.recorder = &MockORMProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockORMProvider) EXPECT() *MockORMProviderMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockORMProvider) Delete(ctx context.Context, ids ...interface{}) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx} + for _, a := range ids { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockORMProviderMockRecorder) Delete(ctx interface{}, ids ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx}, ids...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockORMProvider)(nil).Delete), varargs...) +} + +// Exec mocks base method. +func (m *MockORMProvider) Exec(ctx context.Context, query string, params ...interface{}) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, query} + for _, a := range params { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Exec", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Exec indicates an expected call of Exec. +func (mr *MockORMProviderMockRecorder) Exec(ctx, query interface{}, params ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, query}, params...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockORMProvider)(nil).Exec), varargs...) +} + +// Insert mocks base method. +func (m *MockORMProvider) Insert(ctx context.Context, records ...interface{}) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx} + for _, a := range records { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Insert", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockORMProviderMockRecorder) Insert(ctx interface{}, records ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx}, records...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockORMProvider)(nil).Insert), varargs...) +} + +// Query mocks base method. +func (m *MockORMProvider) Query(ctx context.Context, records interface{}, query string, params ...interface{}) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, records, query} + for _, a := range params { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Query", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Query indicates an expected call of Query. +func (mr *MockORMProviderMockRecorder) Query(ctx, records, query interface{}, params ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, records, query}, params...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Query", reflect.TypeOf((*MockORMProvider)(nil).Query), varargs...) +} + +// QueryChunks mocks base method. +func (m *MockORMProvider) QueryChunks(ctx context.Context, parser kissorm.ChunkParser) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryChunks", ctx, parser) + ret0, _ := ret[0].(error) + return ret0 +} + +// QueryChunks indicates an expected call of QueryChunks. +func (mr *MockORMProviderMockRecorder) QueryChunks(ctx, parser interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryChunks", reflect.TypeOf((*MockORMProvider)(nil).QueryChunks), ctx, parser) +} + +// QueryOne mocks base method. +func (m *MockORMProvider) QueryOne(ctx context.Context, record interface{}, query string, params ...interface{}) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx, record, query} + for _, a := range params { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "QueryOne", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// QueryOne indicates an expected call of QueryOne. +func (mr *MockORMProviderMockRecorder) QueryOne(ctx, record, query interface{}, params ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx, record, query}, params...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryOne", reflect.TypeOf((*MockORMProvider)(nil).QueryOne), varargs...) +} + +// Update mocks base method. +func (m *MockORMProvider) Update(ctx context.Context, records ...interface{}) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx} + for _, a := range records { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockORMProviderMockRecorder) Update(ctx interface{}, records ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx}, records...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockORMProvider)(nil).Update), varargs...) +} diff --git a/go.mod b/go.mod index 7dcfc98..97b4a6a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,10 @@ go 1.14 require ( github.com/ditointernet/go-assert v0.0.0-20200120164340-9e13125a7018 + github.com/golang/mock v1.4.4 github.com/lib/pq v1.1.1 github.com/mattn/go-sqlite3 v1.14.6 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.6.1 + github.com/tj/assert v0.0.3 ) diff --git a/go.sum b/go.sum index b8edc6a..2d374e5 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,34 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ditointernet/go-assert v0.0.0-20200120164340-9e13125a7018 h1:QsFkVafcKOaZoAB4WcyUHdkPbwh+VYwZgYJb/rU6EIM= github.com/ditointernet/go-assert v0.0.0-20200120164340-9e13125a7018/go.mod h1:5C3SWkut69TSdkerzRDxXMRM5x73PGWNcRLe/xKjXhs= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c h1:grhR+C34yXImVGp7EzNk+DTIk+323eIUWOmEevy6bDo= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kiss_orm.go b/kiss_orm.go index 654d5e7..e3eb846 100644 --- a/kiss_orm.go +++ b/kiss_orm.go @@ -8,37 +8,37 @@ import ( "strings" ) -// Client ... -type Client struct { +// DB ... +type DB struct { driver string dialect dialect tableName string db *sql.DB } -// NewClient instantiates a new client -func NewClient( +// New instantiates a new client +func New( dbDriver string, connectionString string, maxOpenConns int, tableName string, -) (Client, error) { +) (DB, error) { db, err := sql.Open(dbDriver, connectionString) if err != nil { - return Client{}, err + return DB{}, err } if err = db.Ping(); err != nil { - return Client{}, err + return DB{}, err } db.SetMaxOpenConns(maxOpenConns) dialect := getDriverDialect(dbDriver) if dialect == nil { - return Client{}, fmt.Errorf("unsupported driver `%s`", dbDriver) + return DB{}, fmt.Errorf("unsupported driver `%s`", dbDriver) } - return Client{ + return DB{ dialect: dialect, driver: dbDriver, db: db, @@ -47,8 +47,8 @@ func NewClient( } // ChangeTable creates a new client configured to query on a different table -func (c Client) ChangeTable(ctx context.Context, tableName string) ORMProvider { - return &Client{ +func (c DB) ChangeTable(ctx context.Context, tableName string) ORMProvider { + return &DB{ db: c.db, tableName: tableName, } @@ -61,7 +61,7 @@ func (c Client) ChangeTable(ctx context.Context, tableName string) ORMProvider { // Note: it is very important to make sure the query will // return a small known number of results, otherwise you risk // of overloading the available memory. -func (c Client) Query( +func (c DB) Query( ctx context.Context, records interface{}, query string, @@ -130,7 +130,7 @@ func (c Client) Query( // // QueryOne returns a ErrRecordNotFound if // the query returns no results. -func (c Client) QueryOne( +func (c DB) QueryOne( ctx context.Context, record interface{}, query string, @@ -182,7 +182,7 @@ func (c Client) QueryOne( // pointers to struct as its only argument and that reflection // will be used to instantiate this argument and to fill it // with the database rows. -func (c Client) QueryChunks( +func (c DB) QueryChunks( ctx context.Context, parser ChunkParser, ) error { @@ -268,7 +268,7 @@ func (c Client) QueryChunks( // // If the original instances have been passed by reference // the ID is automatically updated after insertion is completed. -func (c Client) Insert( +func (c DB) Insert( ctx context.Context, records ...interface{}, ) error { @@ -295,7 +295,7 @@ func (c Client) Insert( return nil } -func (c Client) insertOnPostgres( +func (c DB) insertOnPostgres( ctx context.Context, record interface{}, query string, @@ -331,7 +331,7 @@ func (c Client) insertOnPostgres( return rows.Close() } -func (c Client) insertWithLastInsertID( +func (c DB) insertWithLastInsertID( ctx context.Context, record interface{}, query string, @@ -357,7 +357,7 @@ func (c Client) insertWithLastInsertID( } // Delete deletes one or more instances from the database by id -func (c Client) Delete( +func (c DB) Delete( ctx context.Context, ids ...interface{}, ) error { @@ -375,7 +375,7 @@ func (c Client) Delete( // Update updates the given instances on the database by id. // // Partial updates are supported, i.e. it will ignore nil pointer attributes -func (c Client) Update( +func (c DB) Update( ctx context.Context, records ...interface{}, ) error { @@ -492,208 +492,18 @@ func buildUpdateQuery( return query, args, nil } +// Exec just runs an SQL command on the database returning no rows. +func (c DB) Exec(ctx context.Context, query string, params ...interface{}) error { + _, err := c.db.ExecContext(ctx, query, params...) + return err +} + // This cache is kept as a pkg variable // because the total number of types on a program // should be finite. So keeping a single cache here // works fine. var tagInfoCache = map[reflect.Type]structInfo{} -type structInfo struct { - Names map[int]string - Index map[string]int -} - -// StructToMap converts any struct type to a map based on -// the tag named `kissorm`, i.e. `kissorm:"map_key_name"` -// -// Valid pointers are dereferenced and copied to the map, -// null pointers are ignored. -// -// This function is efficient in the fact that it caches -// the slower steps of the reflection required to perform -// this task. -func StructToMap(obj interface{}) (map[string]interface{}, error) { - v := reflect.ValueOf(obj) - t := v.Type() - - if t.Kind() == reflect.Ptr { - v = v.Elem() - t = t.Elem() - } - if t.Kind() != reflect.Struct { - return nil, fmt.Errorf("input must be a struct or struct pointer") - } - - info := getCachedTagInfo(tagInfoCache, t) - - m := map[string]interface{}{} - for i := 0; i < v.NumField(); i++ { - field := v.Field(i) - ft := field.Type() - if ft.Kind() == reflect.Ptr { - if field.IsNil() { - continue - } - - field = field.Elem() - } - - m[info.Names[i]] = field.Interface() - } - - return m, nil -} - -// This function collects only the names -// that will be used from the input type. -// -// This should save several calls to `Field(i).Tag.Get("foo")` -// which improves performance by a lot. -func getTagNames(t reflect.Type) structInfo { - info := structInfo{ - Names: map[int]string{}, - Index: map[string]int{}, - } - for i := 0; i < t.NumField(); i++ { - name := t.Field(i).Tag.Get("kissorm") - if name == "" { - continue - } - info.Names[i] = name - info.Index[name] = i - } - - return info -} - -// FillStructWith is meant to be used on unit tests to mock -// the response from the database. -// -// The first argument is any struct you are passing to a kissorm func, -// and the second is a map representing a database row you want -// to use to update this struct. -func FillStructWith(record interface{}, dbRow map[string]interface{}) error { - v := reflect.ValueOf(record) - t := v.Type() - - if t.Kind() != reflect.Ptr { - return fmt.Errorf( - "FillStructWith: expected input to be a pointer to struct but got %T", - record, - ) - } - - t = t.Elem() - v = v.Elem() - - if t.Kind() != reflect.Struct { - return fmt.Errorf( - "FillStructWith: expected input kind to be a struct but got %T", - record, - ) - } - - info := getCachedTagInfo(tagInfoCache, t) - - for colName, attr := range dbRow { - attrValue := reflect.ValueOf(attr) - field := v.Field(info.Index[colName]) - fieldType := t.Field(info.Index[colName]).Type - - if !attrValue.Type().ConvertibleTo(fieldType) { - return fmt.Errorf( - "FillStructWith: cannot convert atribute %s of type %v to type %T", - colName, - fieldType, - record, - ) - } - field.Set(attrValue.Convert(fieldType)) - } - - return nil -} - -// FillSliceWith is meant to be used on unit tests to mock -// the response from the database. -// -// The first argument is any slice of structs you are passing to a kissorm func, -// and the second is a slice of maps representing the database rows you want -// to use to update this struct. -func FillSliceWith(entities interface{}, dbRows []map[string]interface{}) error { - sliceRef := reflect.ValueOf(entities) - sliceType := sliceRef.Type() - if sliceType.Kind() != reflect.Ptr { - return fmt.Errorf( - "FillSliceWith: expected input to be a pointer to struct but got %v", - sliceType, - ) - } - - structType, isSliceOfPtrs, err := decodeAsSliceOfStructs(sliceType.Elem()) - if err != nil { - return fmt.Errorf("FillSliceWith: %s", err.Error()) - } - - slice := sliceRef.Elem() - for idx, row := range dbRows { - if slice.Len() <= idx { - var elemValue reflect.Value - elemValue = reflect.New(structType) - if !isSliceOfPtrs { - elemValue = elemValue.Elem() - } - slice = reflect.Append(slice, elemValue) - } - - err := FillStructWith(slice.Index(idx).Addr().Interface(), row) - if err != nil { - return err - } - } - - sliceRef.Elem().Set(slice) - - return nil -} - -// Exec just runs an SQL command on the database returning no rows. -func (c Client) Exec(ctx context.Context, query string, params ...interface{}) error { - _, err := c.db.ExecContext(ctx, query, params...) - return err -} - -func decodeAsSliceOfStructs(slice reflect.Type) ( - structType reflect.Type, - isSliceOfPtrs bool, - err error, -) { - if slice.Kind() != reflect.Slice { - err = fmt.Errorf( - "expected input kind to be a slice but got %v", - slice, - ) - return - } - - elemType := slice.Elem() - isPtr := elemType.Kind() == reflect.Ptr - - if isPtr { - elemType = elemType.Elem() - } - - if elemType.Kind() != reflect.Struct { - err = fmt.Errorf( - "expected input to be a slice of structs but got %v", - slice, - ) - return - } - - return elemType, isPtr, nil -} - var errType = reflect.TypeOf(new(error)).Elem() func parseInputFunc(fn interface{}) (reflect.Type, error) { diff --git a/kiss_orm_test.go b/kiss_orm_test.go index 57b9114..3de164d 100644 --- a/kiss_orm_test.go +++ b/kiss_orm_test.go @@ -32,7 +32,7 @@ func TestQuery(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") var users []User err := c.Query(ctx, &users, `SELECT * FROM users WHERE id=1;`) assert.Equal(t, nil, err) @@ -52,7 +52,7 @@ func TestQuery(t *testing.T) { assert.Equal(t, nil, err) ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") var users []User err = c.Query(ctx, &users, `SELECT * FROM users WHERE name=`+c.dialect.Placeholder(0), "Bia") @@ -73,7 +73,7 @@ func TestQuery(t *testing.T) { assert.Equal(t, nil, err) ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") var users []User err = c.Query(ctx, &users, `SELECT * FROM users WHERE name like `+c.dialect.Placeholder(0), "% Garcia") @@ -96,7 +96,7 @@ func TestQuery(t *testing.T) { assert.Equal(t, nil, err) ctx := context.Background() - c := newTestClient(db, "postgres", "users") + c := newTestDB(db, "postgres", "users") err = c.Query(ctx, &User{}, `SELECT * FROM users WHERE name like `+c.dialect.Placeholder(0), "% Sá") assert.NotEqual(t, nil, err) @@ -127,7 +127,7 @@ func TestQueryOne(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, "postgres", "users") + c := newTestDB(db, "postgres", "users") u := User{} err := c.QueryOne(ctx, &u, `SELECT * FROM users WHERE id=1;`) assert.Equal(t, ErrRecordNotFound, err) @@ -141,7 +141,7 @@ func TestQueryOne(t *testing.T) { assert.Equal(t, nil, err) ctx := context.Background() - c := newTestClient(db, "postgres", "users") + c := newTestDB(db, "postgres", "users") u := User{} err = c.QueryOne(ctx, &u, `SELECT * FROM users WHERE name=`+c.dialect.Placeholder(0), "Bia") @@ -161,7 +161,7 @@ func TestQueryOne(t *testing.T) { assert.Equal(t, nil, err) ctx := context.Background() - c := newTestClient(db, "postgres", "users") + c := newTestDB(db, "postgres", "users") err = c.QueryOne(ctx, &[]User{}, `SELECT * FROM users WHERE name like `+c.dialect.Placeholder(0), "% Sá") assert.NotEqual(t, nil, err) @@ -186,7 +186,7 @@ func TestInsert(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") err = c.Insert(ctx) assert.Equal(t, nil, err) @@ -197,7 +197,7 @@ func TestInsert(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") u := User{ Name: "Fernanda", @@ -230,7 +230,7 @@ func TestDelete(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") u := User{ Name: "Won't be deleted", @@ -260,7 +260,7 @@ func TestDelete(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") u1 := User{ Name: "Fernanda", @@ -308,7 +308,7 @@ func TestDelete(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") u1 := User{ Name: "Fernanda", @@ -373,7 +373,7 @@ func TestUpdate(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") u := User{ Name: "Thay", @@ -398,7 +398,7 @@ func TestUpdate(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") u := User{ Name: "Letícia", @@ -429,7 +429,7 @@ func TestUpdate(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") u := User{ Name: "Letícia", @@ -460,7 +460,7 @@ func TestUpdate(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") type partialUser struct { ID uint `kissorm:"id"` @@ -501,7 +501,7 @@ func TestUpdate(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") type partialUser struct { ID uint `kissorm:"id"` @@ -542,7 +542,7 @@ func TestUpdate(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "non_existing_table") + c := newTestDB(db, driver, "non_existing_table") err = c.Update(ctx, User{ ID: 1, @@ -554,68 +554,6 @@ func TestUpdate(t *testing.T) { } } -func TestStructToMap(t *testing.T) { - type S1 struct { - Name string `kissorm:"name_attr"` - Age int `kissorm:"age_attr"` - } - t.Run("should convert plain structs to maps", func(t *testing.T) { - m, err := StructToMap(S1{ - Name: "my name", - Age: 22, - }) - - assert.Equal(t, nil, err) - assert.Equal(t, map[string]interface{}{ - "name_attr": "my name", - "age_attr": 22, - }, m) - }) - - t.Run("should not ignore zero value attrs, if they are not pointers", func(t *testing.T) { - m, err := StructToMap(S1{ - Name: "", - Age: 0, - }) - - assert.Equal(t, nil, err) - assert.Equal(t, map[string]interface{}{ - "name_attr": "", - "age_attr": 0, - }, m) - }) - - type S2 struct { - Name *string `kissorm:"name"` - Age *int `kissorm:"age"` - } - - t.Run("should not ignore not nil pointers", func(t *testing.T) { - str := "" - age := 0 - m, err := StructToMap(S2{ - Name: &str, - Age: &age, - }) - - assert.Equal(t, nil, err) - assert.Equal(t, map[string]interface{}{ - "name": "", - "age": 0, - }, m) - }) - - t.Run("should ignore nil pointers", func(t *testing.T) { - m, err := StructToMap(S2{ - Name: nil, - Age: nil, - }) - - assert.Equal(t, nil, err) - assert.Equal(t, map[string]interface{}{}, m) - }) -} - func TestQueryChunks(t *testing.T) { for _, driver := range []string{"sqlite3", "postgres"} { t.Run(driver, func(t *testing.T) { @@ -629,7 +567,7 @@ func TestQueryChunks(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") _ = c.Insert(ctx, &User{Name: "User1"}) @@ -665,7 +603,7 @@ func TestQueryChunks(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") _ = c.Insert(ctx, &User{Name: "User1"}) _ = c.Insert(ctx, &User{Name: "User2"}) @@ -703,7 +641,7 @@ func TestQueryChunks(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") _ = c.Insert(ctx, &User{Name: "User1"}) _ = c.Insert(ctx, &User{Name: "User2"}) @@ -741,7 +679,7 @@ func TestQueryChunks(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") _ = c.Insert(ctx, &User{Name: "User1"}) _ = c.Insert(ctx, &User{Name: "User2"}) @@ -782,7 +720,7 @@ func TestQueryChunks(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") _ = c.Insert(ctx, &User{Name: "User1"}) _ = c.Insert(ctx, &User{Name: "User2"}) @@ -821,7 +759,7 @@ func TestQueryChunks(t *testing.T) { defer db.Close() ctx := context.Background() - c := newTestClient(db, driver, "users") + c := newTestDB(db, driver, "users") _ = c.Insert(ctx, &User{Name: "User1"}) _ = c.Insert(ctx, &User{Name: "User2"}) @@ -857,29 +795,6 @@ func TestQueryChunks(t *testing.T) { } } -func TestFillSliceWith(t *testing.T) { - t.Run("should fill a list correctly", func(t *testing.T) { - var users []User - err := FillSliceWith(&users, []map[string]interface{}{ - { - "name": "Jorge", - }, - { - "name": "Luciana", - }, - { - "name": "Breno", - }, - }) - - assert.Equal(t, nil, err) - assert.Equal(t, 3, len(users)) - assert.Equal(t, "Jorge", users[0].Name) - assert.Equal(t, "Luciana", users[1].Name) - assert.Equal(t, "Breno", users[2].Name) - }) -} - func TestScanRows(t *testing.T) { t.Run("should scan users correctly", func(t *testing.T) { err := createTable("sqlite3") @@ -890,7 +805,7 @@ func TestScanRows(t *testing.T) { ctx := context.TODO() db := connectDB(t, "sqlite3") defer db.Close() - c := newTestClient(db, "sqlite3", "users") + c := newTestDB(db, "sqlite3", "users") _ = c.Insert(ctx, &User{Name: "User1", Age: 22}) _ = c.Insert(ctx, &User{Name: "User2", Age: 14}) _ = c.Insert(ctx, &User{Name: "User3", Age: 43}) @@ -1010,8 +925,8 @@ func createTable(driver string) error { return nil } -func newTestClient(db *sql.DB, driver string, tableName string) Client { - return Client{ +func newTestDB(db *sql.DB, driver string, tableName string) DB { + return DB{ driver: driver, dialect: getDriverDialect(driver), db: db, diff --git a/mocks.go b/mocks.go new file mode 100644 index 0000000..9ab1688 --- /dev/null +++ b/mocks.go @@ -0,0 +1,43 @@ +package kissorm + +import "context" + +type MockORMProvider struct { + InsertFn func(ctx context.Context, records ...interface{}) error + DeleteFn func(ctx context.Context, ids ...interface{}) error + UpdateFn func(ctx context.Context, records ...interface{}) error + + QueryFn func(ctx context.Context, records interface{}, query string, params ...interface{}) error + QueryOneFn func(ctx context.Context, record interface{}, query string, params ...interface{}) error + QueryChunksFn func(ctx context.Context, parser ChunkParser) error + + ExecFn func(ctx context.Context, query string, params ...interface{}) error +} + +func (m MockORMProvider) Insert(ctx context.Context, records ...interface{}) error { + return m.InsertFn(ctx, records...) +} + +func (m MockORMProvider) Delete(ctx context.Context, ids ...interface{}) error { + return m.DeleteFn(ctx, ids...) +} + +func (m MockORMProvider) Update(ctx context.Context, records ...interface{}) error { + return m.UpdateFn(ctx, records...) +} + +func (m MockORMProvider) Query(ctx context.Context, records interface{}, query string, params ...interface{}) error { + return m.QueryFn(ctx, records, query, params...) +} + +func (m MockORMProvider) QueryOne(ctx context.Context, record interface{}, query string, params ...interface{}) error { + return m.QueryOneFn(ctx, record, query, params...) +} + +func (m MockORMProvider) QueryChunks(ctx context.Context, parser ChunkParser) error { + return m.QueryChunksFn(ctx, parser) +} + +func (m MockORMProvider) Exec(ctx context.Context, query string, params ...interface{}) error { + return m.ExecFn(ctx, query, params...) +} diff --git a/nullable/nullable.go b/nullable/nullable.go index b52e52c..ffce707 100644 --- a/nullable/nullable.go +++ b/nullable/nullable.go @@ -5,6 +5,56 @@ func Int(i int) *int { return &i } +// Int8 ... +func Int8(i int8) *int8 { + return &i +} + +// Int16 ... +func Int16(i int16) *int16 { + return &i +} + +// Int32 ... +func Int32(i int32) *int32 { + return &i +} + +// Int64 ... +func Int64(i int64) *int64 { + return &i +} + +// UInt ... +func UInt(i int) *int { + return &i +} + +// UInt8 ... +func UInt8(i int8) *int8 { + return &i +} + +// UInt16 ... +func UInt16(i int16) *int16 { + return &i +} + +// UInt32 ... +func UInt32(i int32) *int32 { + return &i +} + +// UInt64 ... +func UInt64(i int64) *int64 { + return &i +} + +// Float32 ... +func Float32(f float32) *float32 { + return &f +} + // Float64 ... func Float64(f float64) *float64 { return &f @@ -14,3 +64,28 @@ func Float64(f float64) *float64 { func String(s string) *string { return &s } + +// Bool ... +func Bool(b bool) *bool { + return &b +} + +// Rune ... +func Rune(r rune) *rune { + return &r +} + +// Byte ... +func Byte(b byte) *byte { + return &b +} + +// Complex64 ... +func Complex64(c complex64) *complex64 { + return &c +} + +// Complex128 ... +func Complex128(c complex128) *complex128 { + return &c +} diff --git a/structs.go b/structs.go new file mode 100644 index 0000000..d1d270e --- /dev/null +++ b/structs.go @@ -0,0 +1,267 @@ +package kissorm + +import ( + "fmt" + "reflect" + + "github.com/pkg/errors" +) + +type structInfo struct { + Names map[int]string + Index map[string]int +} + +// StructToMap converts any struct type to a map based on +// the tag named `kissorm`, i.e. `kissorm:"map_key_name"` +// +// Valid pointers are dereferenced and copied to the map, +// null pointers are ignored. +// +// This function is efficient in the fact that it caches +// the slower steps of the reflection required to perform +// this task. +func StructToMap(obj interface{}) (map[string]interface{}, error) { + v := reflect.ValueOf(obj) + t := v.Type() + + if t.Kind() == reflect.Ptr { + v = v.Elem() + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("input must be a struct or struct pointer") + } + + info := getCachedTagInfo(tagInfoCache, t) + + m := map[string]interface{}{} + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + ft := field.Type() + if ft.Kind() == reflect.Ptr { + if field.IsNil() { + continue + } + + field = field.Elem() + } + + m[info.Names[i]] = field.Interface() + } + + return m, nil +} + +// FillStructWith is meant to be used on unit tests to mock +// the response from the database. +// +// The first argument is any struct you are passing to a kissorm func, +// and the second is a map representing a database row you want +// to use to update this struct. +func FillStructWith(record interface{}, dbRow map[string]interface{}) error { + v := reflect.ValueOf(record) + t := v.Type() + + if t.Kind() != reflect.Ptr { + return fmt.Errorf( + "FillStructWith: expected input to be a pointer to struct but got %T", + record, + ) + } + + t = t.Elem() + v = v.Elem() + + if t.Kind() != reflect.Struct { + return fmt.Errorf( + "FillStructWith: expected input kind to be a struct but got %T", + record, + ) + } + + info := getCachedTagInfo(tagInfoCache, t) + + for colName, rawSrc := range dbRow { + src := NewPtrConverter(rawSrc) + dest := v.Field(info.Index[colName]) + destType := t.Field(info.Index[colName]).Type + + destValue, err := src.Convert(destType) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("FillStructWith: error on field `%s`", colName)) + } + + dest.Set(destValue) + } + + return nil +} + +// This type was created to make it easier +// to handle conversion between ptr and non ptr types, e.g.: +// +// - *type to *type +// - type to *type +// - *type to type +// - type to type +type PtrConverter struct { + BaseType reflect.Type + BaseValue reflect.Value + ElemType reflect.Type + ElemValue reflect.Value +} + +func NewPtrConverter(v interface{}) PtrConverter { + if v == nil { + // This is necessary so that reflect.ValueOf + // returns a valid reflect.Value + v = (*interface{})(nil) + } + + baseValue := reflect.ValueOf(v) + baseType := reflect.TypeOf(v) + + elemType := baseType + elemValue := baseValue + if baseType.Kind() == reflect.Ptr { + elemType = elemType.Elem() + elemValue = elemValue.Elem() + } + return PtrConverter{ + BaseType: baseType, + BaseValue: baseValue, + ElemType: elemType, + ElemValue: elemValue, + } +} + +func (p PtrConverter) Convert(destType reflect.Type) (reflect.Value, error) { + destElemType := destType + if destType.Kind() == reflect.Ptr { + destElemType = destType.Elem() + } + + // Return 0 valued destType instance: + if p.BaseType.Kind() == reflect.Ptr && p.BaseValue.IsNil() { + // Note that if destType is a ptr it will return a nil ptr. + return reflect.New(destType).Elem(), nil + } + + if !p.ElemType.ConvertibleTo(destElemType) { + return reflect.Value{}, fmt.Errorf( + "cannot convert from type %v to type %v", p.BaseType, destType, + ) + } + + destValue := p.ElemValue.Convert(destElemType) + + // Get the address of destValue if necessary: + if destType.Kind() == reflect.Ptr { + if !destValue.CanAddr() { + tmp := reflect.New(destElemType) + tmp.Elem().Set(destValue) + destValue = tmp + } else { + destValue = destValue.Addr() + } + } + + return destValue, nil +} + +// FillSliceWith is meant to be used on unit tests to mock +// the response from the database. +// +// The first argument is any slice of structs you are passing to a kissorm func, +// and the second is a slice of maps representing the database rows you want +// to use to update this struct. +func FillSliceWith(entities interface{}, dbRows []map[string]interface{}) error { + sliceRef := reflect.ValueOf(entities) + sliceType := sliceRef.Type() + if sliceType.Kind() != reflect.Ptr { + return fmt.Errorf( + "FillSliceWith: expected input to be a pointer to struct but got %v", + sliceType, + ) + } + + structType, isSliceOfPtrs, err := decodeAsSliceOfStructs(sliceType.Elem()) + if err != nil { + return errors.Wrap(err, "FillSliceWith") + } + + slice := sliceRef.Elem() + for idx, row := range dbRows { + if slice.Len() <= idx { + var elemValue reflect.Value + elemValue = reflect.New(structType) + if !isSliceOfPtrs { + elemValue = elemValue.Elem() + } + slice = reflect.Append(slice, elemValue) + } + + err := FillStructWith(slice.Index(idx).Addr().Interface(), row) + if err != nil { + return errors.Wrap(err, "FillSliceWith") + } + } + + sliceRef.Elem().Set(slice) + + return nil +} + +// This function collects only the names +// that will be used from the input type. +// +// This should save several calls to `Field(i).Tag.Get("foo")` +// which improves performance by a lot. +func getTagNames(t reflect.Type) structInfo { + info := structInfo{ + Names: map[int]string{}, + Index: map[string]int{}, + } + for i := 0; i < t.NumField(); i++ { + name := t.Field(i).Tag.Get("kissorm") + if name == "" { + continue + } + info.Names[i] = name + info.Index[name] = i + } + + return info +} + +func decodeAsSliceOfStructs(slice reflect.Type) ( + structType reflect.Type, + isSliceOfPtrs bool, + err error, +) { + if slice.Kind() != reflect.Slice { + err = fmt.Errorf( + "expected input kind to be a slice but got %v", + slice, + ) + return + } + + elemType := slice.Elem() + isPtr := elemType.Kind() == reflect.Ptr + + if isPtr { + elemType = elemType.Elem() + } + + if elemType.Kind() != reflect.Struct { + err = fmt.Errorf( + "expected input to be a slice of structs but got %v", + slice, + ) + return + } + + return elemType, isPtr, nil +} diff --git a/structs_test.go b/structs_test.go new file mode 100644 index 0000000..b55c02c --- /dev/null +++ b/structs_test.go @@ -0,0 +1,211 @@ +package kissorm + +import ( + "testing" + + "github.com/ditointernet/go-assert" + "github.com/vingarcia/kissorm/nullable" +) + +func TestStructToMap(t *testing.T) { + type S1 struct { + Name string `kissorm:"name_attr"` + Age int `kissorm:"age_attr"` + } + t.Run("should convert plain structs to maps", func(t *testing.T) { + m, err := StructToMap(S1{ + Name: "my name", + Age: 22, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, map[string]interface{}{ + "name_attr": "my name", + "age_attr": 22, + }, m) + }) + + t.Run("should not ignore zero value attrs, if they are not pointers", func(t *testing.T) { + m, err := StructToMap(S1{ + Name: "", + Age: 0, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, map[string]interface{}{ + "name_attr": "", + "age_attr": 0, + }, m) + }) + + type S2 struct { + Name *string `kissorm:"name"` + Age *int `kissorm:"age"` + } + + t.Run("should not ignore not nil pointers", func(t *testing.T) { + str := "" + age := 0 + m, err := StructToMap(S2{ + Name: &str, + Age: &age, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, map[string]interface{}{ + "name": "", + "age": 0, + }, m) + }) + + t.Run("should ignore nil pointers", func(t *testing.T) { + m, err := StructToMap(S2{ + Name: nil, + Age: nil, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, map[string]interface{}{}, m) + }) +} + +func TestFillStructWith(t *testing.T) { + t.Run("should fill a struct correctly", func(t *testing.T) { + var user struct { + Name string `kissorm:"name"` + Age int `kissorm:"age"` + } + err := FillStructWith(&user, map[string]interface{}{ + "name": "Breno", + "age": 22, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, "Breno", user.Name) + assert.Equal(t, 22, user.Age) + }) + + t.Run("should fill ptr fields with ptr values", func(t *testing.T) { + var user struct { + Name *string `kissorm:"name"` + Age *int `kissorm:"age"` + } + err := FillStructWith(&user, map[string]interface{}{ + "name": nullable.String("Breno"), + "age": nullable.Int(22), + }) + + assert.Equal(t, nil, err) + assert.Equal(t, nullable.String("Breno"), user.Name) + assert.Equal(t, nullable.Int(22), user.Age) + }) + + t.Run("should fill ptr fields with non-ptr values", func(t *testing.T) { + var user struct { + Name *string `kissorm:"name"` + Age *int `kissorm:"age"` + } + err := FillStructWith(&user, map[string]interface{}{ + "name": "Breno", + "age": 22, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, nullable.String("Breno"), user.Name) + assert.Equal(t, nullable.Int(22), user.Age) + }) + + t.Run("should fill non ptr fields with ptr values", func(t *testing.T) { + var user struct { + Name string `kissorm:"name"` + Age int `kissorm:"age"` + } + err := FillStructWith(&user, map[string]interface{}{ + "name": nullable.String("Breno"), + "age": nullable.Int(22), + }) + + assert.Equal(t, nil, err) + assert.Equal(t, "Breno", user.Name) + assert.Equal(t, 22, user.Age) + }) + + t.Run("should fill ptr fields with nil when necessary", func(t *testing.T) { + var user struct { + Name *string `kissorm:"name"` + Age *int `kissorm:"age"` + } + err := FillStructWith(&user, map[string]interface{}{ + "name": nil, + "age": nil, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, (*string)(nil), user.Name) + assert.Equal(t, (*int)(nil), user.Age) + }) + + t.Run("should interpret nil fields as zero values when necessary", func(t *testing.T) { + var user struct { + Name string `kissorm:"name"` + Age int `kissorm:"age"` + } + user.Name = "not empty" + user.Age = 42 + + err := FillStructWith(&user, map[string]interface{}{ + "name": nil, + "age": nil, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, "", user.Name) + assert.Equal(t, 0, user.Age) + }) + + t.Run("should ignore extra or missing fields", func(t *testing.T) { + var user struct { + Name string `kissorm:"name"` + Age int `kissorm:"age"` + Missing string `kissorm:"missing"` + } + user.Missing = "should be untouched" + + err := FillStructWith(&user, map[string]interface{}{ + "name": "fake name", + "age": 42, + "extra_field": "some value", + }) + + assert.Equal(t, nil, err) + assert.Equal(t, "fake name", user.Name) + assert.Equal(t, 42, user.Age) + assert.Equal(t, "should be untouched", user.Missing) + }) +} + +func TestFillSliceWith(t *testing.T) { + t.Run("should fill a list correctly", func(t *testing.T) { + var users []struct { + Name string `kissorm:"name"` + Age int `kissorm:"age"` + } + err := FillSliceWith(&users, []map[string]interface{}{ + { + "name": "Jorge", + }, + { + "name": "Luciana", + }, + { + "name": "Breno", + }, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, 3, len(users)) + assert.Equal(t, "Jorge", users[0].Name) + assert.Equal(t, "Luciana", users[1].Name) + assert.Equal(t, "Breno", users[2].Name) + }) +}