From ab6d6f3a2477b9dc5f7a77eca3cffb3fce866c87 Mon Sep 17 00:00:00 2001 From: Dean Karn Date: Wed, 14 Jun 2023 08:16:26 -0700 Subject: [PATCH] SQL plus improvements (#34) ## PR - Added `strconv.ParseBool` which is like the std libraries `ParseBool` but with some additional cases added and should be a drop in replacement. - Updated/extended `Options` SQL decoding abilitys --- CHANGELOG.md | 8 ++- README.md | 2 +- strconv/bool.go | 21 ++++++ values/option/option.go | 130 +++++++++++++++++++++++------------ values/option/option_test.go | 106 ++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 strconv/bool.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7fcd9..f1868ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.19.0] - 2023-06-14 +### Added +- strconvext.ParseBool(...) which is a drop-in replacement for the std lin strconv.ParseBool(..) with a few more supported values. +- Expanded Option type SQL Scan support to handle Scanning to an Interface, Struct, Slice, Map and anything that implements the sql.Scanner interface. + ## [5.18.0] - 2023-05-21 ### Added - typesext.Nothing & valuesext.Nothing for better clarity in generic params and values that represent struct{}. This will provide better code readability and intent. @@ -50,7 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `timext.NanoTime` for fast low level monotonic time with nanosecond precision. -[Unreleased]: https://github.com/go-playground/pkg/compare/v5.18.0...HEAD +[Unreleased]: https://github.com/go-playground/pkg/compare/v5.19.0...HEAD +[5.19.0]: https://github.com/go-playground/pkg/compare/v5.18.0..v5.19.0 [5.18.0]: https://github.com/go-playground/pkg/compare/v5.17.2..v5.18.0 [5.17.2]: https://github.com/go-playground/pkg/compare/v5.17.1..v5.17.2 [5.17.1]: https://github.com/go-playground/pkg/compare/v5.17.0...v5.17.1 diff --git a/README.md b/README.md index 91a8d94..d6a0445 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pkg -![Project status](https://img.shields.io/badge/version-5.18.0-green.svg) +![Project status](https://img.shields.io/badge/version-5.19.0-green.svg) [![Lint & Test](https://github.com/go-playground/pkg/actions/workflows/go.yml/badge.svg)](https://github.com/go-playground/pkg/actions/workflows/go.yml) [![Coverage Status](https://coveralls.io/repos/github/go-playground/pkg/badge.svg?branch=master)](https://coveralls.io/github/go-playground/pkg?branch=master) [![GoDoc](https://godoc.org/github.com/go-playground/pkg?status.svg)](https://pkg.go.dev/mod/github.com/go-playground/pkg/v5) diff --git a/strconv/bool.go b/strconv/bool.go new file mode 100644 index 0000000..fe436cf --- /dev/null +++ b/strconv/bool.go @@ -0,0 +1,21 @@ +package strconvext + +import ( + "strconv" +) + +// ParseBool returns the boolean value represented by the string. It extends the std library parse bool with a few more +// valid options. +// +// It accepts 1, t, T, true, TRUE, True, on, yes, ok as true values and 0, f, F, false, FALSE, False, off, no as false. +func ParseBool(str string) (bool, error) { + switch str { + case "1", "t", "T", "true", "TRUE", "True", "on", "yes", "ok": + return true, nil + case "", "0", "f", "F", "false", "FALSE", "False", "off", "no": + return false, nil + } + // strconv.NumError mimicking exactly the strconv.ParseBool(..) error and type + // to ensure compatibility with std library. + return false, &strconv.NumError{Func: "ParseBool", Num: string([]byte(str)), Err: strconv.ErrSyntax} +} diff --git a/values/option/option.go b/values/option/option.go index 9eb3927..ddafb6a 100644 --- a/values/option/option.go +++ b/values/option/option.go @@ -12,11 +12,34 @@ import ( "time" ) +var ( + scanType = reflect.TypeOf((*sql.Scanner)(nil)).Elem() + byteSliceType = reflect.TypeOf(([]byte)(nil)) +) + // Option represents a values that represents a values existence. // // nil is usually used on Go however this has two problems: // 1. Checking if the return values is nil is NOT enforced and can lead to panics. // 2. Using nil is not good enough when nil itself is a valid value. +// +// This implements the sql.Scanner interface and can be used as a sql value for reading and writing. It supports: +// - String +// - Bool +// - Uint8 +// - Float64 +// - Int16 +// - Int32 +// - Int64 +// - interface{}/any +// - time.Time +// - Struct - when type is convertable to []byte and assumes JSON. +// - Slice - when type is convertable to []byte and assumes JSON. +// - Map types - when type is convertable to []byte and assumes JSON. +// +// This also implements the `json.Marshaler` and `json.Unmarshaler` interfaces. The only caveat is a None value will result +// in a JSON `null` value. there is no way to hook into the std library to make `omitempty` not produce any value at +// this time. type Option[T any] struct { value T isSome bool @@ -50,7 +73,7 @@ func None[T any]() Option[T] { return Option[T]{} } -// MarshalJSON implements the json.Marshaler interface. +// MarshalJSON implements the `json.Marshaler` interface. func (o Option[T]) MarshalJSON() ([]byte, error) { if o.isSome { return json.Marshal(o.value) @@ -58,7 +81,7 @@ func (o Option[T]) MarshalJSON() ([]byte, error) { return []byte("null"), nil } -// UnmarshalJSON implements the json.Unmarshaler interface. +// UnmarshalJSON implements the `json.Unmarshaler` interface. func (o *Option[T]) UnmarshalJSON(data []byte) error { if len(data) == 4 && string(data[:4]) == "null" { *o = None[T]() @@ -83,93 +106,110 @@ func (o Option[T]) Value() (driver.Value, error) { // Scan implements the sql.Scanner interface. func (o *Option[T]) Scan(value any) error { - val := reflect.ValueOf(o.value) + + val := reflect.ValueOf(&o.value) + + if val.Type().Implements(scanType) { + err := val.Interface().(sql.Scanner).Scan(value) + if err != nil { + return err + } + o.isSome = true + return nil + } + + if value == nil { + *o = None[T]() + return nil + } + val = val.Elem() + switch val.Kind() { case reflect.String: var v sql.NullString if err := v.Scan(value); err != nil { return err } - if !v.Valid { - *o = None[T]() - } else { - *o = Some(reflect.ValueOf(v.String).Interface().(T)) - } + *o = Some(reflect.ValueOf(v.String).Interface().(T)) case reflect.Bool: var v sql.NullBool if err := v.Scan(value); err != nil { return err } - if !v.Valid { - *o = None[T]() - } else { - *o = Some(reflect.ValueOf(v.Bool).Interface().(T)) - } + *o = Some(reflect.ValueOf(v.Bool).Interface().(T)) case reflect.Uint8: var v sql.NullByte if err := v.Scan(value); err != nil { return err } - if !v.Valid { - *o = None[T]() - } else { - *o = Some(reflect.ValueOf(v.Byte).Interface().(T)) - } + *o = Some(reflect.ValueOf(v.Byte).Interface().(T)) case reflect.Float64: var v sql.NullFloat64 if err := v.Scan(value); err != nil { return err } - if !v.Valid { - *o = None[T]() - } else { - *o = Some(reflect.ValueOf(v.Float64).Interface().(T)) - } + *o = Some(reflect.ValueOf(v.Float64).Interface().(T)) case reflect.Int16: var v sql.NullInt16 if err := v.Scan(value); err != nil { return err } - if !v.Valid { - *o = None[T]() - } else { - *o = Some(reflect.ValueOf(v.Int16).Interface().(T)) - } + *o = Some(reflect.ValueOf(v.Int16).Interface().(T)) case reflect.Int32: var v sql.NullInt32 if err := v.Scan(value); err != nil { return err } - if !v.Valid { - *o = None[T]() - } else { - *o = Some(reflect.ValueOf(v.Int32).Interface().(T)) - } + *o = Some(reflect.ValueOf(v.Int32).Interface().(T)) case reflect.Int64: var v sql.NullInt64 if err := v.Scan(value); err != nil { return err } - if !v.Valid { - *o = None[T]() - } else { - *o = Some(reflect.ValueOf(v.Int64).Interface().(T)) - } + *o = Some(reflect.ValueOf(v.Int64).Interface().(T)) + case reflect.Interface: + *o = Some(reflect.ValueOf(value).Interface().(T)) case reflect.Struct: if val.Type() == reflect.TypeOf(time.Time{}) { - var v sql.NullTime - if err := v.Scan(value); err != nil { - return err - } - if !v.Valid { - *o = None[T]() - } else { + switch t := value.(type) { + case string: + tm, err := time.Parse(time.RFC3339Nano, t) + if err != nil { + return err + } + *o = Some(reflect.ValueOf(tm).Interface().(T)) + + case []byte: + tm, err := time.Parse(time.RFC3339Nano, string(t)) + if err != nil { + return err + } + *o = Some(reflect.ValueOf(tm).Interface().(T)) + + default: + var v sql.NullTime + if err := v.Scan(value); err != nil { + return err + } *o = Some(reflect.ValueOf(v.Time).Interface().(T)) } return nil } fallthrough + default: + switch val.Kind() { + case reflect.Struct, reflect.Slice, reflect.Map: + v := reflect.ValueOf(value) + + if v.Type().ConvertibleTo(byteSliceType) { + if err := json.Unmarshal(v.Convert(byteSliceType).Interface().([]byte), &o.value); err != nil { + return err + } + o.isSome = true + return nil + } + } return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", value, o.value) } return nil diff --git a/values/option/option_test.go b/values/option/option_test.go index 19d4f9c..0d213f6 100644 --- a/values/option/option_test.go +++ b/values/option/option_test.go @@ -11,6 +11,112 @@ import ( . "github.com/go-playground/assert/v2" ) +type customScanner struct { + S string +} + +func (c *customScanner) Scan(src interface{}) error { + c.S = src.(string) + return nil +} + +func TestSQL(t *testing.T) { + value := int64(123) + var optionI64 Option[int64] + var optionI32 Option[int32] + var optionI16 Option[int16] + var optionString Option[string] + var optionBool Option[bool] + var optionF64 Option[float64] + var optionByte Option[byte] + var optionTime Option[time.Time] + var optionInterface Option[any] + + err := optionInterface.Scan(1) + Equal(t, err, nil) + Equal(t, optionInterface, Some(any(1))) + + err = optionInterface.Scan("blah") + Equal(t, err, nil) + Equal(t, optionInterface, Some(any("blah"))) + + err = optionI64.Scan(value) + Equal(t, err, nil) + Equal(t, optionI64, Some(value)) + + err = optionI32.Scan(value) + Equal(t, err, nil) + Equal(t, optionI32, Some(int32(value))) + + err = optionI16.Scan(value) + Equal(t, err, nil) + Equal(t, optionI16, Some(int16(value))) + + err = optionBool.Scan(1) + Equal(t, err, nil) + Equal(t, optionBool, Some(true)) + + err = optionString.Scan(value) + Equal(t, err, nil) + Equal(t, optionString, Some("123")) + + err = optionF64.Scan(2.0) + Equal(t, err, nil) + Equal(t, optionF64, Some(2.0)) + + err = optionByte.Scan(uint8('1')) + Equal(t, err, nil) + Equal(t, optionByte, Some(uint8('1'))) + + err = optionTime.Scan("2023-06-13T06:34:32Z") + Equal(t, err, nil) + Equal(t, optionTime, Some(time.Date(2023, 6, 13, 6, 34, 32, 0, time.UTC))) + + err = optionTime.Scan([]byte("2023-06-13T06:34:32Z")) + Equal(t, err, nil) + Equal(t, optionTime, Some(time.Date(2023, 6, 13, 6, 34, 32, 0, time.UTC))) + + err = optionTime.Scan(time.Date(2023, 6, 13, 6, 34, 32, 0, time.UTC)) + Equal(t, err, nil) + Equal(t, optionTime, Some(time.Date(2023, 6, 13, 6, 34, 32, 0, time.UTC))) + + // Test nil + var nullableOption Option[int64] + err = nullableOption.Scan(nil) + Equal(t, err, nil) + Equal(t, nullableOption, None[int64]()) + + // custom scanner + var custom Option[customScanner] + err = custom.Scan("GOT HERE") + Equal(t, err, nil) + Equal(t, custom, Some(customScanner{S: "GOT HERE"})) + + // test unmarshal to struct + type myStruct struct { + Name string `json:"name"` + } + + var optionMyStruct Option[myStruct] + err = optionMyStruct.Scan([]byte(`{"name":"test"}`)) + Equal(t, err, nil) + Equal(t, optionMyStruct, Some(myStruct{Name: "test"})) + + err = optionMyStruct.Scan(json.RawMessage(`{"name":"test2"}`)) + Equal(t, err, nil) + Equal(t, optionMyStruct, Some(myStruct{Name: "test2"})) + + var optionArrayOfMyStruct Option[[]myStruct] + err = optionArrayOfMyStruct.Scan([]byte(`[{"name":"test"}]`)) + Equal(t, err, nil) + Equal(t, optionArrayOfMyStruct, Some([]myStruct{{Name: "test"}})) + + var optionMap Option[map[string]any] + err = optionMap.Scan([]byte(`{"name":"test"}`)) + Equal(t, err, nil) + Equal(t, optionMap, Some(map[string]any{"name": "test"})) +} + func TestNilOption(t *testing.T) { value := Some[any](nil) Equal(t, false, value.IsNone())