diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0709ac5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "go.lintTool": "golangci-lint", + "go.formatFlags": ["-s", "-w"], + "go.lintOnSave": "package", + "go.lintFlags": ["-c", "~/.dotfiles/.golangci.yml", "--issues-exit-code=0"], + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "go.coverOnSave": true, + "go.coverageOptions": "showBothCoveredAndUncoveredCode", + "go.testOnSave": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe7349..9b51a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.8] - 2024-08-13 + +### Changed + +- Modified how number values are derived, allowing values to be cast as the various types. + +### Fixed + +- Panicing when type is asserted to be what it isn't. + ## [1.0.7] - 2024-02-29 ### Added @@ -135,7 +145,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.5.5] - 2022-07-12 -- Fixed bug where string literals of `\t` and `\r` would result in generating an invalid JSON. +- Fixed bug where string literals of `\t` and `\r` would result in generating an invalid JSON. ### Changed @@ -161,14 +171,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - - Updated supported types for Additional Data, unsupported types now throwing an error instead of ignoring. - - Changed logic that trims excessive commas to be called only once on serialization. +- Updated supported types for Additional Data, unsupported types now throwing an error instead of ignoring. +- Changed logic that trims excessive commas to be called only once on serialization. ## [0.5.0] - 2022-05-26 ### Changed - - Updated reference to abstractions to support enum responses. +- Updated reference to abstractions to support enum responses. ## [0.4.0] - 2022-05-19 diff --git a/intersection_type_wrapper_test.go b/intersection_type_wrapper_test.go index dfa789f..2bf82d5 100644 --- a/intersection_type_wrapper_test.go +++ b/intersection_type_wrapper_test.go @@ -31,7 +31,7 @@ func TestItParsesIntersectionTypeComplexProperty1(t *testing.T) { } func TestItParsesIntersectionTypeComplexProperty2(t *testing.T) { - source := "{\"displayName\":\"McGill\",\"officeLocation\":\"Montreal\", \"id\": 10}" + source := "{\"displayName\":\"McGill\",\"officeLocation\":\"Montreal\", \"id\": \"10\"}" sourceArray := []byte(source) parseNode, err := NewJsonParseNode(sourceArray) @@ -39,17 +39,16 @@ func TestItParsesIntersectionTypeComplexProperty2(t *testing.T) { t.Error(err) } result, err := parseNode.GetObjectValue(internal.CreateIntersectionTypeMockFromDiscriminator) - if err != nil { - t.Error(err) - } + assert.Nil(t, err) assert.NotNil(t, result) cast, ok := result.(internal.IntersectionTypeMockable) + assert.NotNil(t, cast) assert.True(t, ok) assert.NotNil(t, cast.GetComposedType1()) assert.NotNil(t, cast.GetComposedType2()) assert.Nil(t, cast.GetStringValue()) assert.Nil(t, cast.GetComposedType3()) - assert.Nil(t, cast.GetComposedType1().GetId()) + assert.NotNil(t, cast.GetComposedType1().GetId()) assert.Nil(t, cast.GetComposedType2().GetId()) // it's expected to be null since we have conflicting properties here and the parser will only try one to avoid having to brute its way through assert.Equal(t, "McGill", *cast.GetComposedType2().GetDisplayName()) } diff --git a/json_parse_node.go b/json_parse_node.go index 2d94796..c6235ac 100644 --- a/json_parse_node.go +++ b/json_parse_node.go @@ -36,6 +36,7 @@ func NewJsonParseNode(content []byte) (*JsonParseNode, error) { value, err := loadJsonTree(decoder) return value, err } + func loadJsonTree(decoder *json.Decoder) (*JsonParseNode, error) { for { token, err := decoder.Token() @@ -158,6 +159,9 @@ func (n *JsonParseNode) setValue(value interface{}) { // GetChildNode returns a new parse node for the given identifier. func (n *JsonParseNode) GetChildNode(index string) (absser.ParseNode, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } if index == "" { return nil, errors.New("index is empty") } @@ -183,12 +187,12 @@ func (n *JsonParseNode) GetChildNode(index string) (absser.ParseNode, error) { // GetObjectValue returns the Parsable value from the node. func (n *JsonParseNode) GetObjectValue(ctor absser.ParsableFactory) (absser.Parsable, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } if ctor == nil { return nil, errors.New("constructor is nil") } - if n == nil || n.value == nil { - return nil, nil - } result, err := ctor(n) if err != nil { return nil, err @@ -297,7 +301,7 @@ func (n *JsonParseNode) GetObjectValue(ctor absser.ParsableFactory) (absser.Pars // GetCollectionOfObjectValues returns the collection of Parsable values from the node. func (n *JsonParseNode) GetCollectionOfObjectValues(ctor absser.ParsableFactory) ([]absser.Parsable, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } if ctor == nil { @@ -324,7 +328,7 @@ func (n *JsonParseNode) GetCollectionOfObjectValues(ctor absser.ParsableFactory) // GetCollectionOfPrimitiveValues returns the collection of primitive values from the node. func (n *JsonParseNode) GetCollectionOfPrimitiveValues(targetType string) ([]interface{}, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } if targetType == "" { @@ -348,7 +352,11 @@ func (n *JsonParseNode) GetCollectionOfPrimitiveValues(targetType string) ([]int } return result, nil } + func (n *JsonParseNode) getPrimitiveValue(targetType string) (interface{}, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } switch targetType { case "string": return n.GetStringValue() @@ -385,7 +393,7 @@ func (n *JsonParseNode) getPrimitiveValue(targetType string) (interface{}, error // GetCollectionOfEnumValues returns the collection of Enum values from the node. func (n *JsonParseNode) GetCollectionOfEnumValues(parser absser.EnumFactory) ([]interface{}, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } if parser == nil { @@ -412,90 +420,119 @@ func (n *JsonParseNode) GetCollectionOfEnumValues(parser absser.EnumFactory) ([] // GetStringValue returns a String value from the nodes. func (n *JsonParseNode) GetStringValue() (*string, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - res, ok := n.value.(*string) - if ok { - return res, nil - } else { - return nil, nil + + val, ok := n.value.(*string) + if !ok { + return nil, fmt.Errorf("type '%T' is not compatible with type string", n.value) } + return val, nil } // GetBoolValue returns a Bool value from the nodes. func (n *JsonParseNode) GetBoolValue() (*bool, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - return n.value.(*bool), nil + + val, ok := n.value.(*bool) + if !ok { + return nil, fmt.Errorf("type '%T' is not compatible with type bool", n.value) + } + return val, nil } // GetInt8Value returns a int8 value from the nodes. func (n *JsonParseNode) GetInt8Value() (*int8, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - return n.value.(*int8), nil + var val int8 + + if err := as(n.value, &val); err != nil { + return nil, err + } + + return &val, nil } // GetBoolValue returns a Bool value from the nodes. func (n *JsonParseNode) GetByteValue() (*byte, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - return n.value.(*byte), nil + var val byte + + if err := as(n.value, &val); err != nil { + return nil, err + } + + return &val, nil } // GetFloat32Value returns a Float32 value from the nodes. func (n *JsonParseNode) GetFloat32Value() (*float32, error) { - v, err := n.GetFloat64Value() - if err != nil { - return nil, err - } - if v == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - cast := float32(*v) - return &cast, nil + var val float32 + + if err := as(n.value, &val); err != nil { + return nil, err + } + + return &val, nil } // GetFloat64Value returns a Float64 value from the nodes. func (n *JsonParseNode) GetFloat64Value() (*float64, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - return n.value.(*float64), nil + var val float64 + + if err := as(n.value, &val); err != nil { + return nil, err + } + + return &val, nil } // GetInt32Value returns a Int32 value from the nodes. func (n *JsonParseNode) GetInt32Value() (*int32, error) { - v, err := n.GetFloat64Value() - if err != nil { - return nil, err - } - if v == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - cast := int32(*v) - return &cast, nil + var val int32 + + if err := as(n.value, &val); err != nil { + return nil, err + } + + return &val, nil } // GetInt64Value returns a Int64 value from the nodes. func (n *JsonParseNode) GetInt64Value() (*int64, error) { - v, err := n.GetFloat64Value() - if err != nil { - return nil, err - } - if v == nil { + if isNil(n) || isNil(n.value) { return nil, nil } - cast := int64(*v) - return &cast, nil + var val int64 + + if err := as(n.value, &val); err != nil { + return nil, err + } + + return &val, nil } // GetTimeValue returns a Time value from the nodes. func (n *JsonParseNode) GetTimeValue() (*time.Time, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } v, err := n.GetStringValue() if err != nil { return nil, err @@ -514,6 +551,9 @@ func (n *JsonParseNode) GetTimeValue() (*time.Time, error) { // GetISODurationValue returns a ISODuration value from the nodes. func (n *JsonParseNode) GetISODurationValue() (*absser.ISODuration, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } v, err := n.GetStringValue() if err != nil { return nil, err @@ -526,6 +566,9 @@ func (n *JsonParseNode) GetISODurationValue() (*absser.ISODuration, error) { // GetTimeOnlyValue returns a TimeOnly value from the nodes. func (n *JsonParseNode) GetTimeOnlyValue() (*absser.TimeOnly, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } v, err := n.GetStringValue() if err != nil { return nil, err @@ -538,6 +581,9 @@ func (n *JsonParseNode) GetTimeOnlyValue() (*absser.TimeOnly, error) { // GetDateOnlyValue returns a DateOnly value from the nodes. func (n *JsonParseNode) GetDateOnlyValue() (*absser.DateOnly, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } v, err := n.GetStringValue() if err != nil { return nil, err @@ -550,6 +596,9 @@ func (n *JsonParseNode) GetDateOnlyValue() (*absser.DateOnly, error) { // GetUUIDValue returns a UUID value from the nodes. func (n *JsonParseNode) GetUUIDValue() (*uuid.UUID, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } v, err := n.GetStringValue() if err != nil { return nil, err @@ -563,6 +612,9 @@ func (n *JsonParseNode) GetUUIDValue() (*uuid.UUID, error) { // GetEnumValue returns a Enum value from the nodes. func (n *JsonParseNode) GetEnumValue(parser absser.EnumFactory) (interface{}, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } if parser == nil { return nil, errors.New("parser is nil") } @@ -578,6 +630,9 @@ func (n *JsonParseNode) GetEnumValue(parser absser.EnumFactory) (interface{}, er // GetByteArrayValue returns a ByteArray value from the nodes. func (n *JsonParseNode) GetByteArrayValue() ([]byte, error) { + if isNil(n) || isNil(n.value) { + return nil, nil + } s, err := n.GetStringValue() if err != nil { return nil, err @@ -590,7 +645,7 @@ func (n *JsonParseNode) GetByteArrayValue() ([]byte, error) { // GetRawValue returns a ByteArray value from the nodes. func (n *JsonParseNode) GetRawValue() (interface{}, error) { - if n == nil || n.value == nil { + if isNil(n) || isNil(n.value) { return nil, nil } switch v := n.value.(type) { diff --git a/json_parse_node_test.go b/json_parse_node_test.go index 6eaed38..015efbb 100644 --- a/json_parse_node_test.go +++ b/json_parse_node_test.go @@ -1,6 +1,8 @@ package jsonserialization import ( + "errors" + "reflect" "testing" "github.com/microsoft/kiota-serialization-json-go/internal" @@ -321,6 +323,418 @@ func TestUntypedJsonObject(t *testing.T) { } } +func TestJsonGetStringValue(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`"I am a string"`), + Expected: "I am a string", + Error: nil, + }, + { + //Intentionally does not work, see https://github.com/microsoft/kiota-serialization-json-go/issues/142 + Title: "Integer", + Input: []byte(`1`), + Expected: (*string)(nil), + Error: errors.New("type '*float64' is not compatible with type string"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetStringValue() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + +func TestJsonGetBoolValue(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`true`), + Expected: true, + Error: nil, + }, + { + Title: "Integer", + Input: []byte(`1`), + Expected: (*bool)(nil), + Error: errors.New("type '*float64' is not compatible with type bool"), + }, + { + Title: "String", + Input: []byte(`"true"`), + Expected: (*bool)(nil), + Error: errors.New("type '*string' is not compatible with type bool"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetBoolValue() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + +func TestJsonGetInt8Value(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`1`), + Expected: int8(1), + Error: nil, + }, + { + Title: "Bool", + Input: []byte(`true`), + Expected: (*int8)(nil), + Error: errors.New("value 'true' is not compatible with type int8"), + }, + { + Title: "String", + Input: []byte(`"1"`), + Expected: (*int8)(nil), + Error: errors.New("value '1' is not compatible with type int8"), + }, + { + Title: "Float", + Input: []byte(`1.1`), + Expected: (*int8)(nil), + Error: errors.New("value '1.1' is not compatible with type int8"), + }, + { + Title: "Too Big", + Input: []byte(`129`), + Expected: (*int8)(nil), + Error: errors.New("value '129' is not compatible with type int8"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetInt8Value() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + +func TestJsonGetByteValue(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`1`), + Expected: uint8(1), + Error: nil, + }, + { + Title: "Bool", + Input: []byte(`true`), + Expected: (*uint8)(nil), + Error: errors.New("value 'true' is not compatible with type uint8"), + }, + { + Title: "Float", + Input: []byte(`1.1`), + Expected: (*uint8)(nil), + Error: errors.New("value '1.1' is not compatible with type uint8"), + }, + { + Title: "String", + Input: []byte(`"1"`), + Expected: (*uint8)(nil), + Error: errors.New("value '1' is not compatible with type uint8"), + }, + { + Title: "Too Big", + Input: []byte(`3.40283e+38`), + Expected: (*uint8)(nil), + Error: errors.New("value '3.40283e+38' is not compatible with type uint8"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetByteValue() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + +func TestJsonGetFloat32Value(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`1`), + Expected: float32(1), + Error: nil, + }, + { + Title: "Bool", + Input: []byte(`true`), + Expected: (*float32)(nil), + Error: errors.New("value 'true' is not compatible with type float32"), + }, + { + Title: "String", + Input: []byte(`"1"`), + Expected: (*float32)(nil), + Error: errors.New("value '1' is not compatible with type float32"), + }, + { + Title: "Too Big", + Input: []byte(`3.40283e+38`), + Expected: (*float32)(nil), + Error: errors.New("value '3.40283e+38' is not compatible with type float32"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetFloat32Value() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + +func TestJsonGetFloat64Value(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`1`), + Expected: float64(1), + Error: nil, + }, + { + Title: "Bool", + Input: []byte(`true`), + Expected: (*float64)(nil), + Error: errors.New("value 'true' is not compatible with type float64"), + }, + { + Title: "String", + Input: []byte(`"1"`), + Expected: (*float64)(nil), + Error: errors.New("value '1' is not compatible with type float64"), + }, + //NOTE: no point in checking too big, the STD JSON encoder will error out first :) + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetFloat64Value() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + +func TestJsonGetInt32Value(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`1`), + Expected: int32(1), + Error: nil, + }, + { + Title: "Bool", + Input: []byte(`true`), + Expected: (*int32)(nil), + Error: errors.New("value 'true' is not compatible with type int32"), + }, + { + Title: "Float", + Input: []byte(`1.1`), + Expected: (*int32)(nil), + Error: errors.New("value '1.1' is not compatible with type int32"), + }, + { + Title: "String", + Input: []byte(`"1"`), + Expected: (*int32)(nil), + Error: errors.New("value '1' is not compatible with type int32"), + }, + { + Title: "Too Big", + Input: []byte(`3.40283e+38`), + Expected: (*int32)(nil), + Error: errors.New("value '3.40283e+38' is not compatible with type int32"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetInt32Value() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + +func TestJsonGetInt64Value(t *testing.T) { + cases := []struct { + Title string + Input []byte + Expected interface{} + Error error + }{ + { + Title: "Valid", + Input: []byte(`1`), + Expected: int64(1), + Error: nil, + }, + { + Title: "Bool", + Input: []byte(`true`), + Expected: (*int64)(nil), + Error: errors.New("value 'true' is not compatible with type int64"), + }, + { + Title: "Float", + Input: []byte(`1.1`), + Expected: (*int64)(nil), + Error: errors.New("value '1.1' is not compatible with type int64"), + }, + { + Title: "Too Big", + Input: []byte(`3.40283e+38`), + Expected: (*int64)(nil), + Error: errors.New("value '3.40283e+38' is not compatible with type int64"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + var val any + + node, err := NewJsonParseNode(test.Input) + assert.Nil(t, err) + + val, err = node.GetInt64Value() + + assert.Equal(t, test.Error, err) + v := reflect.ValueOf(val) + if !v.IsNil() && v.Kind() == reflect.Ptr { + val = v.Elem().Interface() + } + assert.Equal(t, test.Expected, val) + }) + } +} + const TestUntypedJson = "{\r\n" + " \"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#sites('contoso.sharepoint.com')/lists('fa631c4d-ac9f-4884-a7f5-13c659d177e3')/items('1')/fields/$entity\",\r\n" + " \"id\": \"5\",\r\n" + diff --git a/util.go b/util.go new file mode 100644 index 0000000..0d017ec --- /dev/null +++ b/util.go @@ -0,0 +1,147 @@ +package jsonserialization + +import ( + "fmt" + "math" + "reflect" +) + +type numericRange struct { + min float64 + max float64 + allowDecimal bool +} + +var ( + numericTypeRanges = map[reflect.Kind]numericRange{ + reflect.Int8: {math.MinInt8, math.MaxInt8, false}, + reflect.Uint8: {0, math.MaxUint8, false}, + reflect.Int16: {math.MinInt16, math.MaxInt16, false}, + reflect.Uint16: {0, math.MaxUint16, false}, + reflect.Int32: {math.MinInt32, math.MaxInt32, false}, + reflect.Uint32: {0, math.MaxUint32, false}, + reflect.Int64: {math.MinInt64, math.MaxInt64, false}, + reflect.Uint64: {0, math.MaxUint64, false}, + reflect.Float32: {-math.MaxFloat32, math.MaxFloat32, true}, + reflect.Float64: {-math.MaxFloat64, math.MaxFloat64, true}, + } +) + +type number interface { + int | int8 | uint8 | int16 | uint16 | int32 | uint32 | int64 | uint64 | float32 | float64 +} + +// isCompatible checks if the value is compatible with the type tp. +// It intentionally excludes checking if types are pointers to allow for possibility. +func isCompatible(value interface{}, tp reflect.Type) bool { + // Can't join with lower, number types are always "convertible" just not losslessly. + if isNumericType(value) && isNumericType(tp) { + //NOTE: no need to check if number is compatible with another, always yes, just overflows + //Check if number value is TRULY compatible + return isCompatibleInt(value, tp) + } + + return reflect.TypeOf(value).ConvertibleTo(tp) +} + +// isNil checks if a value is nil or a nil interface, including nested pointers. +func isNil(a interface{}) bool { + if a == nil { + return true + } + val := reflect.ValueOf(a) + for val.Kind() == reflect.Ptr || val.Kind() == reflect.Interface { + if val.IsNil() { + return true + } + val = val.Elem() + } + switch val.Kind() { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.Interface, reflect.Slice: + return val.IsNil() + } + return false +} + +// as converts the value to the type T. +func as[T any](in interface{}, out T) error { + // No point in trying anything if already nil + if isNil(in) { + return nil + } + + // Make sure nothing is a pointer + valValue := reflect.ValueOf(in) + for valValue.Kind() == reflect.Ptr { + valValue = valValue.Elem() + in = valValue.Interface() + } + + outVal := reflect.ValueOf(out) + if outVal.Kind() != reflect.Pointer || isNil(out) { + return fmt.Errorf("out is not pointer or is nil") + } + + nestedOutVal := outVal.Elem() + // Handle the case where out is a pointer to an interface + if nestedOutVal.Kind() == reflect.Interface && !nestedOutVal.IsNil() { + nestedOutVal = nestedOutVal.Elem() + } + + outType := nestedOutVal.Type() + + if !isCompatible(in, outType) { + return fmt.Errorf("value '%v' is not compatible with type %T", in, nestedOutVal.Interface()) + } + + outVal.Elem().Set(valValue.Convert(outType)) + return nil +} + +// isNumericType checks if the given type is a numeric type. +func isNumericType(in interface{}) bool { + + if in == nil { + return false + } + + tp, ok := in.(reflect.Type) + if !ok { + tp = reflect.TypeOf(in) + } + + switch tp.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return true + default: + return false + } +} + +// isCompatibleInt checks if the given value is compatible with the specified integer type. +// It returns true if the value falls within the valid range for the type and has no decimal places. +// Otherwise, it returns false. +func isCompatibleInt(in interface{}, tp reflect.Type) bool { + if !isNumericType(in) || !isNumericType(tp) { + return false + } + + inFloat := reflect.ValueOf(in).Convert(reflect.TypeOf(float64(0))).Float() + hasDecimal := hasDecimalPlace(inFloat) + + if rangeInfo, ok := numericTypeRanges[tp.Kind()]; ok { + if inFloat >= rangeInfo.min && inFloat <= rangeInfo.max { + return rangeInfo.allowDecimal || !hasDecimal + } + } + return false +} + +// hasDecimalPlace checks if the given float64 value has a decimal place. +// It returns true if the fractional part of the value is greater than 0.0 (indicating a decimal). +// Otherwise, it returns false. +func hasDecimalPlace(value float64) bool { + return value != float64(int64(value)) +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..07b1f36 --- /dev/null +++ b/util_test.go @@ -0,0 +1,317 @@ +package jsonserialization + +import ( + "errors" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCompatible(t *testing.T) { + cases := []struct { + Title string + InputVal interface{} + InputType reflect.Type + Expected interface{} + }{ + { + Title: "Valid", + InputVal: int(5), + InputType: reflect.TypeOf(int8(0)), + Expected: true, + }, + { + Title: "Incompatible", + InputVal: "I am a string", + InputType: reflect.TypeOf(int8(0)), + Expected: false, + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + isComp := isCompatible(test.InputVal, test.InputType) + assert.Equal(t, test.Expected, isComp) + }) + } +} + +func TestIsNil(t *testing.T) { + tests := []struct { + Title string + Input interface{} + Expected bool + }{ + {"nil value", nil, true}, + {"non-nil int", 42, false}, + {"nil pointer", (*int)(nil), true}, + {"non-nil pointer", new(int), false}, + {"nil interface", interface{}(nil), true}, + {"non-nil interface", interface{}(42), false}, + {"nil slice", ([]int)(nil), true}, + {"non-nil slice", []int{1, 2, 3}, false}, + {"nil map", (map[string]int)(nil), true}, + {"non-nil map", map[string]int{"a": 1}, false}, + {"nil chan", (chan int)(nil), true}, + {"non-nil chan", make(chan int), false}, + {"nil func", (func())(nil), true}, + {"non-nil func", func() {}, false}, + {"nested nil pointer", (**int)(nil), true}, + {"nested non-nil pointer", func() **int { var i int; p := &i; return &p }(), false}, + } + + for _, tt := range tests { + t.Run(tt.Title, func(t *testing.T) { + got := isNil(tt.Input) + assert.Equal(t, tt.Expected, got) + }) + } +} + +func TestAs(t *testing.T) { + cases := []struct { + Title string + InputVal []interface{} + Expected interface{} + Error error + }{ + { + Title: "Number", + InputVal: []interface{}{ + int8(1), + int16(0), + }, + Expected: int16(1), + Error: nil, + }, + { + Title: "Incompatible", + InputVal: []interface{}{ + "I am a string", + int8(0), + }, + Expected: int8(0), + Error: errors.New("value 'I am a string' is not compatible with type int8"), + }, + { + Title: "Untyped Nil - In", + InputVal: []interface{}{ + nil, + int8(0), + }, + Expected: int8(0), + Error: nil, + }, + { + Title: "Typed Nil - In", + InputVal: []interface{}{ + (*string)(nil), + int8(0), + }, + Expected: int8(0), + Error: nil, + }, + { + Title: "Untyped Nil - Out", + InputVal: []interface{}{ + int8(1), + nil, + }, + Expected: nil, + Error: errors.New("out is not pointer or is nil"), + }, + { + Title: "Typed Nil - Out", + InputVal: []interface{}{ + int8(0), + (*int8)(nil), + }, + Expected: (*int8)(nil), + Error: errors.New("out is not pointer or is nil"), + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + in := test.InputVal[0] + out := test.InputVal[1] + err := as(in, &out) + + assert.Equal(t, test.Error, err) + assert.Equal(t, test.Expected, out) + }) + } +} + +func TestIsNumericType(t *testing.T) { + cases := []struct { + Title string + InputVal interface{} + Expected bool + }{ + { + Title: "Int", + InputVal: int(1), + Expected: true, + }, + { + Title: "int8", + InputVal: int8(1), + Expected: true, + }, + { + Title: "uint8", + InputVal: uint8(1), + Expected: true, + }, + { + Title: "int16", + InputVal: int16(1), + Expected: true, + }, + { + Title: "uint16", + InputVal: uint16(1), + Expected: true, + }, + { + Title: "int32", + InputVal: int32(1), + Expected: true, + }, + { + Title: "uint32", + InputVal: uint32(1), + Expected: true, + }, + { + Title: "int64", + InputVal: int64(1), + Expected: true, + }, + { + Title: "uint64", + InputVal: uint64(1), + Expected: true, + }, + { + Title: "float32", + InputVal: float32(1.1), + Expected: true, + }, + { + Title: "float64", + InputVal: float64(1.1), + Expected: true, + }, + { + Title: "string", + InputVal: "1.1", + Expected: false, + }, + { + Title: "bool", + InputVal: true, + Expected: false, + }, + { + Title: "Untyped Nil", + InputVal: nil, + Expected: false, + }, + { + Title: "Typed Nil", + InputVal: (*int)(nil), + Expected: false, + }, + { + Title: "Interface", + InputVal: interface{}(int(1)), + Expected: true, + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + isNumber := isNumericType(test.InputVal) + + assert.Equal(t, test.Expected, isNumber) + }) + } +} + +func TestIsCompatibleInt(t *testing.T) { + cases := []struct { + Title string + InputVal interface{} + InputType reflect.Type + Expected bool + }{ + { + Title: "Valid", + InputVal: 1, + InputType: reflect.TypeOf(float64(0)), + Expected: true, + }, + { + Title: "Too Big", + InputVal: 300, + InputType: reflect.TypeOf(int8(0)), + Expected: false, + }, + { + Title: "String", + InputVal: "1", + InputType: reflect.TypeOf(int8(0)), + Expected: false, + }, + { + Title: "Nested Int", + InputVal: interface{}(int64(1)), + InputType: reflect.TypeOf(int8(0)), + Expected: true, + }, + { + Title: "Bool", + InputVal: true, + InputType: reflect.TypeOf(int8(0)), + Expected: false, + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + isNumber := isCompatibleInt(test.InputVal, test.InputType) + + assert.Equal(t, test.Expected, isNumber) + }) + } +} + +func TestHasDecimal(t *testing.T) { + cases := []struct { + Title string + InputVal float64 + Expected bool + }{ + { + Title: "Yes", + InputVal: 1.000005, + Expected: true, + }, + { + Title: "No", + InputVal: 1.0, + Expected: false, + }, + } + + for _, test := range cases { + t.Run(test.Title, func(t *testing.T) { + hasDecimal := hasDecimalPlace(test.InputVal) + + assert.Equal(t, test.Expected, hasDecimal) + }) + } +}