From 51fc2f35caeed3f4f8926802710591ac556491f0 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sat, 6 Nov 2021 20:25:25 +0200 Subject: [PATCH] new /x/jsonx and /x/mathx util sub-packages --- HISTORY.md | 4 ++ x/jsonx/duration.go | 54 +++++++++++++++++ x/jsonx/iso8601.go | 112 ++++++++++++++++++++++++++++++++++ x/jsonx/jsonx.go | 21 +++++++ x/jsonx/kitchen_time.go | 108 +++++++++++++++++++++++++++++++++ x/jsonx/kitchen_time_test.go | 39 ++++++++++++ x/jsonx/simple_date.go | 113 +++++++++++++++++++++++++++++++++++ x/jsonx/time_notation.go | 88 +++++++++++++++++++++++++++ x/mathx/round.go | 28 +++++++++ 9 files changed, 567 insertions(+) create mode 100644 x/jsonx/duration.go create mode 100644 x/jsonx/iso8601.go create mode 100644 x/jsonx/jsonx.go create mode 100644 x/jsonx/kitchen_time.go create mode 100644 x/jsonx/kitchen_time_test.go create mode 100644 x/jsonx/simple_date.go create mode 100644 x/jsonx/time_notation.go create mode 100644 x/mathx/round.go diff --git a/HISTORY.md b/HISTORY.md index f82383ca..7e152467 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -28,6 +28,10 @@ The codebase for Dependency Injection, Internationalization and localization and ## Fixes and Improvements +- New [x/jsonx](x/jsonx) sub-package for JSON type helpers. + +- New [x/mathx](x/mathx) sub-package for math related functions. + - New [/x/client](x/client) HTTP Client sub-package. - New `email` builtin path parameter type. Example: diff --git a/x/jsonx/duration.go b/x/jsonx/duration.go new file mode 100644 index 00000000..b32fc2fe --- /dev/null +++ b/x/jsonx/duration.go @@ -0,0 +1,54 @@ +package jsonx + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "math" + "time" +) + +// Duration is a JSON representation of the standard Duration type, until Go version 2 supports it under the hoods. +type Duration time.Duration + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + switch value := v.(type) { + case float64: + *d = Duration(value) + return nil + case string: + v, err := time.ParseDuration(value) + if err != nil { + return err + } + *d = Duration(v) + return nil + default: + return errors.New("invalid duration") + } +} + +func (d Duration) ToDuration() time.Duration { + return time.Duration(d) +} + +func (d Duration) Value() (driver.Value, error) { + return int64(d), nil +} + +// Set sets the value of duration in nanoseconds. +func (d *Duration) Set(v float64) { + if math.IsNaN(v) { + return + } + + *d = Duration(v) +} diff --git a/x/jsonx/iso8601.go b/x/jsonx/iso8601.go new file mode 100644 index 00000000..464dba73 --- /dev/null +++ b/x/jsonx/iso8601.go @@ -0,0 +1,112 @@ +package jsonx + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +const ( + // ISO8601Layout holds the time layout for the the javascript iso time. + // Read more at: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString. + ISO8601Layout = "2006-01-02T15:04:05" + // ISO8601ZLayout same as ISO8601Layout but with the timezone suffix. + ISO8601ZLayout = "2006-01-02T15:04:05Z" +) + +// ISO8601 describes a time compatible with javascript time format. +type ISO8601 time.Time + +// ParseISO8601 reads from "s" and returns the ISO8601 time. +func ParseISO8601(s string) (ISO8601, error) { + if s == "" || s == "null" { + return ISO8601{}, nil + } + + var ( + tt time.Time + err error + ) + + if s[len(s)-1] == 'Z' { + tt, err = time.Parse(ISO8601ZLayout, s) + } else { + tt, err = time.Parse(ISO8601Layout, s) + } + + if err != nil { + return ISO8601{}, err + } + + return ISO8601(tt.UTC()), nil +} + +// UnmarshalJSON parses the "b" into ISO8601 time. +func (t *ISO8601) UnmarshalJSON(b []byte) error { + if len(b) == 0 { + return nil + } + + s := strings.Trim(string(b), `"`) + tt, err := ParseISO8601(s) + if err != nil { + return err + } + + *t = tt + return nil +} + +// MarshalJSON writes a quoted string in the ISO8601 time format. +func (t ISO8601) MarshalJSON() ([]byte, error) { + if s := t.String(); s != "" { + s = strconv.Quote(s) + return []byte(s), nil + } + + return nullLiteral, nil // Note: if the front-end wants an empty string instead I must change that. +} + +// ToTime returns the unwrapped *t to time.Time. +func (t *ISO8601) ToTime() time.Time { + tt := time.Time(*t) + return tt +} + +// IsZero reports whether "t" is zero time. +// It completes the pg.Zeroer interface. +func (t ISO8601) IsZero() bool { + return time.Time(t).IsZero() +} + +// String returns the text representation of the "t" using the ISO8601 time layout. +func (t ISO8601) String() string { + tt := t.ToTime() + if tt.IsZero() { + return "" + } + + return tt.Format(ISO8601Layout) +} + +// Scan completes the sql driver.Scanner interface. +func (t *ISO8601) Scan(src interface{}) error { + switch v := src.(type) { + case time.Time: // type was set to timestamp + if v.IsZero() { + return nil // don't set zero, ignore it. + } + *t = ISO8601(v) + case string: + tt, err := ParseISO8601(v) + if err != nil { + return err + } + *t = tt + default: + return fmt.Errorf("ISO8601: unknown type of: %T", v) + } + + return nil +} diff --git a/x/jsonx/jsonx.go b/x/jsonx/jsonx.go new file mode 100644 index 00000000..29c954fa --- /dev/null +++ b/x/jsonx/jsonx.go @@ -0,0 +1,21 @@ +package jsonx + +import "bytes" + +var ( + quoteLiteral = '"' + emptyQuoteBytes = []byte(`""`) + nullLiteral = []byte("null") +) + +func isNull(b []byte) bool { + return len(b) == 0 || bytes.Equal(b, nullLiteral) +} + +func trimQuotesFunc(r rune) bool { + return r == quoteLiteral +} + +func trimQuotes(b []byte) []byte { + return bytes.TrimFunc(b, trimQuotesFunc) +} diff --git a/x/jsonx/kitchen_time.go b/x/jsonx/kitchen_time.go new file mode 100644 index 00000000..12197718 --- /dev/null +++ b/x/jsonx/kitchen_time.go @@ -0,0 +1,108 @@ +package jsonx + +import ( + "fmt" + "strconv" + "time" +) + +// KitckenTimeLayout represents the "3:04 PM" Go time format, similar to time.Kitcken. +const KitckenTimeLayout = "3:04 PM" + +// KitckenTime holds a json "3:04 PM" time. +type KitckenTime time.Time + +// ParseKitchenTime reads from "s" and returns the KitckenTime time. +func ParseKitchenTime(s string) (KitckenTime, error) { + if s == "" || s == "null" { + return KitckenTime{}, nil + } + + var ( + tt time.Time + err error + ) + + tt, err = time.Parse(KitckenTimeLayout, s) + if err != nil { + return KitckenTime{}, err + } + + return KitckenTime(tt.UTC()), nil +} + +// UnmarshalJSON binds the json "data" to "t" with the `KitckenTimeLayout`. +func (t *KitckenTime) UnmarshalJSON(data []byte) error { + if isNull(data) { + return nil + } + + data = trimQuotes(data) + + if len(data) == 0 { + return nil + } + + tt, err := time.Parse(KitckenTimeLayout, string(data)) + if err != nil { + return err + } + + *t = KitckenTime(tt) + return nil +} + +// MarshalJSON returns the json representation of the "t". +func (t KitckenTime) MarshalJSON() ([]byte, error) { + if s := t.String(); s != "" { + s = strconv.Quote(s) + return []byte(s), nil + } + + return emptyQuoteBytes, nil +} + +// IsZero reports whether "t" is zero time. +// It completes the pg.Zeroer interface. +func (t KitckenTime) IsZero() bool { + return t.Value().IsZero() +} + +// Value returns the standard time type. +func (t KitckenTime) Value() time.Time { + return time.Time(t) +} + +// String returns the text representation of the date +// formatted based on the `KitckenTimeLayout`. +// If date is zero it returns an empty string. +func (t KitckenTime) String() string { + tt := t.Value() + if tt.IsZero() { + return "" + } + + return tt.Format(KitckenTimeLayout) +} + +// Scan completes the pg and native sql driver.Scanner interface +// reading functionality of a custom type. +func (t *KitckenTime) Scan(src interface{}) error { + switch v := src.(type) { + case time.Time: // type was set to timestamp + if v.IsZero() { + return nil // don't set zero, ignore it. + } + *t = KitckenTime(v) + case string: + tt, err := ParseKitchenTime(v) + if err != nil { + return err + } + *t = tt + default: + return fmt.Errorf("KitckenTime: unknown type of: %T", v) + } + + return nil +} diff --git a/x/jsonx/kitchen_time_test.go b/x/jsonx/kitchen_time_test.go new file mode 100644 index 00000000..d757ba59 --- /dev/null +++ b/x/jsonx/kitchen_time_test.go @@ -0,0 +1,39 @@ +package jsonx + +import ( + "encoding/json" + "testing" + "time" +) + +func TestJSONKitckenTime(t *testing.T) { + data := `{"start": "8:33 AM", "end": "3:04 PM", "nothing": null, "empty": ""}` + v := struct { + Start KitckenTime `json:"start"` + End KitckenTime `json:"end"` + Nothing KitckenTime `json:"nothing"` + Empty KitckenTime `json:"empty"` + }{} + err := json.Unmarshal([]byte(data), &v) + if err != nil { + t.Fatal(err) + } + + if !v.Nothing.IsZero() { + t.Fatalf("expected 'nothing' to be zero but got: %v", v.Nothing) + } + + if !v.Empty.IsZero() { + t.Fatalf("expected 'empty' to be zero but got: %v", v.Empty) + } + + loc := time.UTC + + if expected, got := time.Date(0, time.January, 1, 8, 33, 0, 0, loc), v.Start.Value(); expected != got { + t.Fatalf("expected 'start' to be: %v but got: %v", expected, got) + } + + if expected, got := time.Date(0, time.January, 1, 15, 4, 0, 0, loc), v.End.Value(); expected != got { + t.Fatalf("expected 'start' to be: %v but got: %v", expected, got) + } +} diff --git a/x/jsonx/simple_date.go b/x/jsonx/simple_date.go new file mode 100644 index 00000000..df8d20e9 --- /dev/null +++ b/x/jsonx/simple_date.go @@ -0,0 +1,113 @@ +package jsonx + +import ( + "database/sql/driver" + "fmt" + "strconv" + "time" +) + +// SimpleDateLayout represents the "year-month-day" Go time format. +const SimpleDateLayout = "2006-01-02" + +// SimpleDate holds a json "year-month-day" time. +type SimpleDate time.Time + +// ParseSimpleDate reads from "s" and returns the SimpleDate time. +func ParseSimpleDate(s string) (SimpleDate, error) { + if s == "" || s == "null" { + return SimpleDate{}, nil + } + + var ( + tt time.Time + err error + ) + + tt, err = time.Parse(SimpleDateLayout, s) + if err != nil { + return SimpleDate{}, err + } + + return SimpleDate(tt.UTC()), nil +} + +// UnmarshalJSON binds the json "data" to "t" with the `SimpleDateLayout`. +func (t *SimpleDate) UnmarshalJSON(data []byte) error { + if isNull(data) { + return nil + } + + data = trimQuotes(data) + dataStr := string(data) + if len(dataStr) == 0 { + return nil // as an excepption here, allow empty "" on simple dates, as the server would render it on a response: https://endomedical.slack.com/archives/D02BF660JA1/p1630486704048100. + } + + tt, err := time.Parse(SimpleDateLayout, dataStr) + if err != nil { + return err + } + + *t = SimpleDate(tt) + return nil +} + +// MarshalJSON returns the json representation of the "t". +func (t SimpleDate) MarshalJSON() ([]byte, error) { + if s := t.String(); s != "" { + s = strconv.Quote(s) + return []byte(s), nil + } + + return emptyQuoteBytes, nil +} + +// IsZero reports whether "t" is zero time. +// It completes the pg.Zeroer interface. +func (t SimpleDate) IsZero() bool { + return t.ToTime().IsZero() +} + +// ToTime returns the standard time type. +func (t SimpleDate) ToTime() time.Time { + return time.Time(t) +} + +func (t SimpleDate) Value() (driver.Value, error) { + return t.String(), nil +} + +// String returns the text representation of the date +// formatted based on the `SimpleDateLayout`. +// If date is zero it returns an empty string. +func (t SimpleDate) String() string { + tt := t.ToTime() + if tt.IsZero() { + return "" + } + + return tt.Format(SimpleDateLayout) +} + +// Scan completes the pg and native sql driver.Scanner interface +// reading functionality of a custom type. +func (t *SimpleDate) Scan(src interface{}) error { + switch v := src.(type) { + case time.Time: // type was set to timestamp + if v.IsZero() { + return nil // don't set zero, ignore it. + } + *t = SimpleDate(v) + case string: + tt, err := ParseSimpleDate(v) + if err != nil { + return err + } + *t = tt + default: + return fmt.Errorf("SimpleDate: unknown type of: %T", v) + } + + return nil +} diff --git a/x/jsonx/time_notation.go b/x/jsonx/time_notation.go new file mode 100644 index 00000000..03bd05f5 --- /dev/null +++ b/x/jsonx/time_notation.go @@ -0,0 +1,88 @@ +package jsonx + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// TimeNotationDuration is a JSON representation of the standard Duration type in 00:00:00 (hour, minute seconds). +type TimeNotationDuration time.Duration + +func fmtDuration(d time.Duration) string { + d = d.Round(time.Minute) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + return fmt.Sprintf("%02d:%02d", h, m) +} + +func (d TimeNotationDuration) MarshalJSON() ([]byte, error) { + v := d.ToDuration() + + format := fmtDuration(v) + return []byte(strconv.Quote(format)), nil +} + +func (d *TimeNotationDuration) UnmarshalJSON(b []byte) error { + if len(b) == 0 || bytes.Equal(b, nullLiteral) || bytes.Equal(b, emptyQuoteBytes) { // if null or empty don't throw an error. + return nil + } + + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + switch value := v.(type) { + case float64: + *d = TimeNotationDuration(value) + return nil + case string: + entries := strings.SplitN(value, ":", 2) + if len(entries) < 2 { + return fmt.Errorf("invalid duration format: expected hours:minutes:seconds (e.g. 01:05) but got: %s", value) + } + + hours, err := strconv.Atoi(entries[0]) + if err != nil { + return err + } + minutes, err := strconv.Atoi(entries[1]) + if err != nil { + return err + } + + format := fmt.Sprintf("%02dh%02dm", hours, minutes) + v, err := time.ParseDuration(format) + if err != nil { + return err + } + *d = TimeNotationDuration(v) + return nil + default: + return errors.New("invalid duration") + } +} + +func (d TimeNotationDuration) ToDuration() time.Duration { + return time.Duration(d) +} + +func (d TimeNotationDuration) Value() (driver.Value, error) { + return int64(d), nil +} + +// Set sets the value of duration in nanoseconds. +func (d *TimeNotationDuration) Set(v float64) { + if math.IsNaN(v) { + return + } + + *d = TimeNotationDuration(v) +} diff --git a/x/mathx/round.go b/x/mathx/round.go new file mode 100644 index 00000000..f9bfb078 --- /dev/null +++ b/x/mathx/round.go @@ -0,0 +1,28 @@ +package mathx + +import "math" + +// Round rounds the "input" on "roundOn" (e.g. 0.5) on "places" digits. +func Round(input float64, roundOn float64, places float64) float64 { + pow := math.Pow(10, places) + digit := pow * input + + _, div := math.Modf(digit) + if div >= roundOn { + return math.Ceil(digit) / pow + } + + return math.Floor(digit) / pow +} + +// RoundUp rounds up the "input" up to "places" digits. +func RoundUp(input float64, places float64) float64 { + pow := math.Pow(10, places) + return math.Ceil(pow*input) / pow +} + +// RoundDown rounds down the "input" up to "places" digits. +func RoundDown(input float64, places float64) float64 { + pow := math.Pow(10, places) + return math.Floor(pow*input) / pow +}