diff --git a/examples/example_service/example_service_test.go b/examples/example_service/example_service_test.go index 1cddac4..1f690c1 100644 --- a/examples/example_service/example_service_test.go +++ b/examples/example_service/example_service_test.go @@ -7,7 +7,7 @@ import ( "github.com/ditointernet/go-assert" gomock "github.com/golang/mock/gomock" "github.com/vingarcia/ksql" - "github.com/vingarcia/ksql/kstructs" + "github.com/vingarcia/ksql/ksqltest" "github.com/vingarcia/ksql/nullable" ) @@ -58,7 +58,7 @@ func TestCreateUser(t *testing.T) { // // If you are inserting an anonymous struct (not usual) this function // can make your tests shorter: - uMap, err := kstructs.StructToMap(record) + uMap, err := ksqltest.StructToMap(record) if err != nil { return err } @@ -95,7 +95,7 @@ func TestUpdateUserScore(t *testing.T) { 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 kstructs.FillStructWith(result, map[string]interface{}{ + return ksqltest.FillStructWith(result, map[string]interface{}{ // Use int this map the keys you set on the ksql tags, e.g. `ksql:"score"` // Each of these fields represent the database rows returned // by the query. @@ -138,7 +138,7 @@ func TestListUsers(t *testing.T) { 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 kstructs.FillStructWith(result, map[string]interface{}{ + return ksqltest.FillStructWith(result, map[string]interface{}{ // Use int this map the keys you set on the ksql tags, e.g. `ksql:"score"` // Each of these fields represent the database rows returned // by the query. @@ -147,7 +147,7 @@ func TestListUsers(t *testing.T) { }), mockDB.EXPECT().Query(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, results interface{}, query string, params ...interface{}) error { - return kstructs.FillSliceWith(results, []map[string]interface{}{ + return ksqltest.FillSliceWith(results, []map[string]interface{}{ { "id": 1, "name": "fake name", @@ -198,7 +198,7 @@ func TestStreamAllUsers(t *testing.T) { mockDB.EXPECT().QueryChunks(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, parser ksql.ChunkParser) error { // Chunk 1: - err := kstructs.CallFunctionWithRows(parser.ForEachChunk, []map[string]interface{}{ + err := ksqltest.CallFunctionWithRows(parser.ForEachChunk, []map[string]interface{}{ { "id": 1, "name": "fake name", @@ -215,7 +215,7 @@ func TestStreamAllUsers(t *testing.T) { } // Chunk 2: - err = kstructs.CallFunctionWithRows(parser.ForEachChunk, []map[string]interface{}{ + err = ksqltest.CallFunctionWithRows(parser.ForEachChunk, []map[string]interface{}{ { "id": 3, "name": "yet another fake name", diff --git a/ksql.go b/ksql.go index 66260b5..501352a 100644 --- a/ksql.go +++ b/ksql.go @@ -9,7 +9,7 @@ import ( "github.com/pkg/errors" "github.com/vingarcia/ksql/internal/structs" - "github.com/vingarcia/ksql/kstructs" + "github.com/vingarcia/ksql/ksqltest" ) var selectQueryCache = map[string]map[reflect.Type]string{} @@ -593,7 +593,7 @@ func normalizeIDsAsMap(idNames []string, idOrMap interface{}) (idMap map[string] switch t.Kind() { case reflect.Struct: - idMap, err = kstructs.StructToMap(idOrMap) + idMap, err = ksqltest.StructToMap(idOrMap) if err != nil { return nil, errors.Wrapf(err, "could not get ID(s) from input record") } @@ -691,7 +691,7 @@ func buildInsertQuery( info structs.StructInfo, record interface{}, ) (query string, params []interface{}, scanValues []interface{}, err error) { - recordMap, err := kstructs.StructToMap(record) + recordMap, err := ksqltest.StructToMap(record) if err != nil { return "", nil, nil, err } @@ -785,7 +785,7 @@ func buildUpdateQuery( record interface{}, idFieldNames ...string, ) (query string, args []interface{}, err error) { - recordMap, err := kstructs.StructToMap(record) + recordMap, err := ksqltest.StructToMap(record) if err != nil { return "", nil, err } @@ -927,7 +927,7 @@ func scanRowsFromType( if info.IsNestedStruct { // This version is positional meaning that it expect the arguments // to follow an specific order. It's ok because we don't allow the - // user to type the "SELECT" part of the query for nested kstructs. + // user to type the "SELECT" part of the query for nested ksqltest. scanArgs, err = getScanArgsForNestedStructs(dialect, rows, t, v, info) if err != nil { return err diff --git a/ksqltest/testhelpers.go b/ksqltest/testhelpers.go new file mode 100644 index 0000000..c9f0099 --- /dev/null +++ b/ksqltest/testhelpers.go @@ -0,0 +1,144 @@ +package ksqltest + +import ( + "fmt" + "reflect" + + "github.com/pkg/errors" + + "github.com/vingarcia/ksql/internal/structs" +) + +// StructToMap converts any struct type to a map based on +// the tag named `ksql`, i.e. `ksql:"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) { + return structs.StructToMap(obj) +} + +// 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 ksql 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 to be a pointer to a struct, but got %T", + record, + ) + } + + info, err := structs.GetTagInfo(t) + if err != nil { + return err + } + + for colName, rawSrc := range dbRow { + fieldInfo := info.ByName(colName) + if !fieldInfo.Valid { + // Ignore columns not tagged with `ksql:"..."` + continue + } + + src := structs.NewPtrConverter(rawSrc) + dest := v.Field(fieldInfo.Index) + destType := t.Field(fieldInfo.Index).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 +} + +// 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 ksql 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 a slice of structs but got %v", + sliceType, + ) + } + + structType, isSliceOfPtrs, err := structs.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 +} + +// CallFunctionWithRows was created for helping test the QueryChunks method +func CallFunctionWithRows(fn interface{}, rows []map[string]interface{}) error { + fnValue := reflect.ValueOf(fn) + chunkType, err := structs.ParseInputFunc(fn) + if err != nil { + return err + } + + chunk := reflect.MakeSlice(chunkType, 0, len(rows)) + + // Create a pointer to a slice (required by FillSliceWith) + chunkPtr := reflect.New(chunkType) + chunkPtr.Elem().Set(chunk) + + err = FillSliceWith(chunkPtr.Interface(), rows) + if err != nil { + return err + } + + err, _ = fnValue.Call([]reflect.Value{chunkPtr.Elem()})[0].Interface().(error) + + return err +} diff --git a/ksqltest/testhelpers_test.go b/ksqltest/testhelpers_test.go new file mode 100644 index 0000000..a678177 --- /dev/null +++ b/ksqltest/testhelpers_test.go @@ -0,0 +1,395 @@ +package ksqltest + +import ( + "fmt" + "testing" + + "github.com/ditointernet/go-assert" + tt "github.com/vingarcia/ksql/internal/testtools" + "github.com/vingarcia/ksql/nullable" +) + +func TestStructToMap(t *testing.T) { + type S1 struct { + Name string `ksql:"name_attr"` + Age int `ksql:"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 `ksql:"name"` + Age *int `ksql:"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) + }) + + t.Run("should ignore fields not tagged with ksql", func(t *testing.T) { + m, err := StructToMap(struct { + Name string `ksql:"name_attr"` + Age int `ksql:"age_attr"` + NotPartOfTheQuery int + }{ + Name: "fake-name", + Age: 42, + NotPartOfTheQuery: 42, + }) + + assert.Equal(t, nil, err) + assert.Equal(t, map[string]interface{}{ + "name_attr": "fake-name", + "age_attr": 42, + }, m) + }) + + t.Run("should return error for duplicated ksql tag names", func(t *testing.T) { + _, err := StructToMap(struct { + Name string `ksql:"name_attr"` + DuplicatedName string `ksql:"name_attr"` + Age int `ksql:"age_attr"` + }{ + Name: "fake-name", + Age: 42, + DuplicatedName: "fake-duplicated-name", + }) + + assert.NotEqual(t, nil, err) + }) + + t.Run("should return error for structs with no ksql tags", func(t *testing.T) { + _, err := StructToMap(struct { + Name string + Age int `json:"age"` + }{ + Name: "fake-name", + Age: 42, + }) + + assert.NotEqual(t, nil, err) + }) +} + +func TestFillStructWith(t *testing.T) { + t.Run("should fill a struct correctly", func(t *testing.T) { + var user struct { + Name string `ksql:"name"` + Age int `ksql:"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 `ksql:"name"` + Age *int `ksql:"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 `ksql:"name"` + Age *int `ksql:"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 `ksql:"name"` + Age int `ksql:"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 `ksql:"name"` + Age *int `ksql:"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 `ksql:"name"` + Age int `ksql:"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 `ksql:"name"` + Age int `ksql:"age"` + Missing string `ksql:"missing"` + } + user.Missing = "should be untouched" + + err := FillStructWith(&user, map[string]interface{}{ + "name": "fake name", + "age": 42, + "extra_field": "some value", + }) + + tt.AssertEqual(t, nil, err) + tt.AssertEqual(t, "fake name", user.Name) + tt.AssertEqual(t, 42, user.Age) + tt.AssertEqual(t, "should be untouched", user.Missing) + }) + + t.Run("should report error if input is not a pointer", func(t *testing.T) { + type User struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + Missing string `ksql:"missing"` + } + var user User + + err := FillStructWith(user, map[string]interface{}{ + "name": "fake name", + "age": 42, + "extra_field": "some value", + }) + + tt.AssertErrContains(t, err, "FillStructWith", "expected input to be a pointer", "User") + }) + + t.Run("should report error if input is not a pointer to struct", func(t *testing.T) { + type User struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + Missing string `ksql:"missing"` + } + var users []User + + err := FillStructWith(&users, map[string]interface{}{ + "name": "fake name", + "age": 42, + "extra_field": "some value", + }) + + tt.AssertErrContains(t, err, "FillStructWith", "expected input to be a pointer to a struct", "User") + }) + + t.Run("should report error if input and target types are incompatible", func(t *testing.T) { + type User struct { + Age int `ksql:"age"` + } + var user User + + err := FillStructWith(&user, map[string]interface{}{ + "age": "not compatible with integer type", + }) + + tt.AssertErrContains(t, err, "FillStructWith", "age", "string", "int") + }) +} + +func TestFillSliceWith(t *testing.T) { + t.Run("should fill a list correctly", func(t *testing.T) { + var users []struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + } + err := FillSliceWith(&users, []map[string]interface{}{ + { + "name": "Jorge", + }, + { + "name": "Luciana", + }, + { + "name": "Breno", + }, + }) + + tt.AssertEqual(t, err, nil) + tt.AssertEqual(t, len(users), 3) + tt.AssertEqual(t, users[0].Name, "Jorge") + tt.AssertEqual(t, users[1].Name, "Luciana") + tt.AssertEqual(t, users[2].Name, "Breno") + }) + + t.Run("should report error if input is not a pointer", func(t *testing.T) { + var users []struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + } + err := FillSliceWith(users, []map[string]interface{}{{ + "name": "Jorge", + }}) + + tt.AssertErrContains(t, err, "FillSliceWith", "expected input to be a pointer") + }) + + t.Run("should report error if input is not a pointer to a slice", func(t *testing.T) { + var user struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + } + err := FillSliceWith(&user, []map[string]interface{}{{ + "name": "Jorge", + }}) + + tt.AssertErrContains(t, err, "FillSliceWith") + }) +} + +func TestCallFunctionWithRows(t *testing.T) { + t.Run("should call the function correctly", func(t *testing.T) { + type User struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + } + + var inputUsers []User + fn := func(users []User) error { + inputUsers = users + return nil + } + + err := CallFunctionWithRows(fn, []map[string]interface{}{ + { + "name": "fake-name1", + "age": 42, + }, + { + "name": "fake-name2", + "age": 43, + }, + }) + tt.AssertNoErr(t, err) + tt.AssertEqual(t, inputUsers, []User{ + { + Name: "fake-name1", + Age: 42, + }, + { + Name: "fake-name2", + Age: 43, + }, + }) + }) + + t.Run("should forward errors correctly", func(t *testing.T) { + type User struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + } + + fn := func(users []User) error { + return fmt.Errorf("fake-error-msg") + } + + err := CallFunctionWithRows(fn, []map[string]interface{}{{ + "name": "fake-name1", + "age": 42, + }}) + tt.AssertErrContains(t, err, "fake-error-msg") + }) + + t.Run("should report error if the input function is invalid", func(t *testing.T) { + type User struct { + Name string `ksql:"name"` + Age int `ksql:"age"` + } + + err := CallFunctionWithRows(func() {}, []map[string]interface{}{{ + "name": "fake-name1", + "age": 42, + }}) + tt.AssertErrContains(t, err) + }) +} diff --git a/kstructs/testhelpers.go b/kstructs/testhelpers.go index da5c66d..792e20f 100644 --- a/kstructs/testhelpers.go +++ b/kstructs/testhelpers.go @@ -1,3 +1,4 @@ +// Package kstructs is deprecated: use ksqltest instead. package kstructs import (