From 2a118c71d12275c371c2df3364c9f583c653a8a8 Mon Sep 17 00:00:00 2001 From: Nut Pinyo Date: Thu, 16 Nov 2023 20:32:44 +0700 Subject: [PATCH 1/3] feat: add nullable package --- go.work | 1 + nullable/README.md | 12 + nullable/go.mod | 14 ++ nullable/go.sum | 12 + nullable/nullable.go | 137 +++++++++++ nullable/nullable_test.go | 470 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 646 insertions(+) create mode 100644 nullable/README.md create mode 100644 nullable/go.mod create mode 100644 nullable/go.sum create mode 100644 nullable/nullable.go create mode 100644 nullable/nullable_test.go diff --git a/go.work b/go.work index 95c136c..d450c60 100644 --- a/go.work +++ b/go.work @@ -10,6 +10,7 @@ use ( ./address ./errors ./fixedpoint + ./nullable ./queue ./utils ) diff --git a/nullable/README.md b/nullable/README.md new file mode 100644 index 0000000..01e013f --- /dev/null +++ b/nullable/README.md @@ -0,0 +1,12 @@ +[![Go Reference](https://pkg.go.dev/badge/github.com/Cleverse/go-utilities/nullable.svg)](https://pkg.go.dev/github.com/Cleverse/go-utilities/nullable) +[![Report card](https://goreportcard.com/badge/github.com/Cleverse/go-utilities/nullable)](https://goreportcard.com/report/github.com/Cleverse/go-utilities/nullable) + +# nullable + +A safe way to represent nullable primitive values in Go. Supports JSON serialization. + +## Installation + +```shell +go get github.com/Cleverse/go-utilities/nullable +``` diff --git a/nullable/go.mod b/nullable/go.mod new file mode 100644 index 0000000..cacf7b1 --- /dev/null +++ b/nullable/go.mod @@ -0,0 +1,14 @@ +module github.com/Cleverse/go-utilities/nullable + +go 1.21 + +require ( + github.com/Cleverse/go-utilities/errors v0.0.0-20231113142714-2364608744a9 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/nullable/go.sum b/nullable/go.sum new file mode 100644 index 0000000..61f568d --- /dev/null +++ b/nullable/go.sum @@ -0,0 +1,12 @@ +github.com/Cleverse/go-utilities/errors v0.0.0-20231113142714-2364608744a9 h1:nHdki9biYL351wAyweRn7AOtEtOcIUN30DK7Xbx8dG0= +github.com/Cleverse/go-utilities/errors v0.0.0-20231113142714-2364608744a9/go.mod h1:1QK+h746G1DwellQ6KK2rBCJusZqIDTZ9QFVGnUX9+Q= +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/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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/nullable/nullable.go b/nullable/nullable.go new file mode 100644 index 0000000..3a064e6 --- /dev/null +++ b/nullable/nullable.go @@ -0,0 +1,137 @@ +// Package nullable provides a safe way to represent nullable primitive values in Go. +package nullable + +import ( + "bytes" + "encoding/json" + + "github.com/Cleverse/go-utilities/errors" +) + +type primitive interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~float32 | ~float64 | ~bool | ~string +} + +var nullBytes = []byte("null") + +// Nullable is a generic type that can be used to represent a nullable value. If valid is true, then data is considered non-null. +// If valid is false, then data is considered null. Nullable supports all primitive types, except pointers, slices, maps, channels and structs. +// Nullable supports +type Nullable[T primitive] struct { + valid bool + data T +} + +// New returns a new null Nullable. +func New[T primitive]() Nullable[T] { + return Nullable[T]{} +} + +// From returns a non-null Nullable with the given data. +func From[T primitive](data T) Nullable[T] { + return Nullable[T]{ + valid: true, + data: data, + } +} + +// Zero returns a non-null Nullable with the zero value of the given type. +func Zero[T primitive]() Nullable[T] { + return Nullable[T]{ + valid: true, + } +} + +// Get returns the data and a boolean indicating whether the Nullable is considered null or non-null. +// If boolean is false, then Nullable is considered null. If boolean is true, then Nullable is considered non-null. +func (n Nullable[T]) Get() (T, bool) { + return n.data, n.valid +} + +// Data returns the without checking if Nullable is considered null. Only use this if you are sure that Nullable is non-null. +func (n Nullable[T]) Data() T { + return n.data +} + +// Set sets the data and marks it as non-null. +func (n *Nullable[T]) Set(data T) { + n.valid = true + n.data = data +} + +// SetNull marks the data as null. +func (n *Nullable[T]) SetNull() { + var zero T + n.valid = false + n.data = zero +} + +// SetZero sets the data to the zero value of the given type and marks it as non-null. +func (n *Nullable[T]) SetZero() { + var zero T + n.valid = true + n.data = zero +} + +// IsValid returns true if the Nullable is non-null. +func (n Nullable[T]) IsValid() bool { + return n.valid +} + +// IsZero returns true if the Nullable is non-null and is the zero value of the given type. +func (n Nullable[T]) IsZero() bool { + if !n.valid { + return false + } + var zero T + return n.data == zero +} + +// Ptr returns a pointer to the data. If the Nullable is null, then nil is returned. +func (n Nullable[T]) Ptr() *T { + if !n.valid { + return nil + } + return &n.data +} + +// Equal returns true if both Nullable are null or if both Nullable are non-null and have the same data. +func (n Nullable[T]) Equal(other Nullable[T]) bool { + if !n.valid && !other.valid { + return true + } + if n.valid && other.valid && n.data == other.data { + return true + } + return false +} + +// MarshalJSON implements json.Marshaler interface. If the Nullable is considered null, then "null" is returned. +func (n Nullable[T]) MarshalJSON() ([]byte, error) { + if !n.valid { + return json.Marshal(nil) + } + data, err := json.Marshal(n.data) + if err != nil { + return nil, errors.WithStack(err) + } + return data, nil +} + +// UnmarshalJSON implements json.Unmarshaler interface. If "null" is passed, then the Nullable is marked as null. +// Otherwise, the data is marked as non-null and the data is unmarshalled. +func (n *Nullable[T]) UnmarshalJSON(data []byte) error { + if bytes.EqualFold(data, nullBytes) { + n.valid = false + return nil + } + if err := json.Unmarshal(data, &n.data); err != nil { + n.valid = false + return errors.WithStack(err) + } + + n.valid = true + return nil +} diff --git a/nullable/nullable_test.go b/nullable/nullable_test.go new file mode 100644 index 0000000..c3049de --- /dev/null +++ b/nullable/nullable_test.go @@ -0,0 +1,470 @@ +package nullable + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEqual(t *testing.T) { + type args[T primitive] struct { + A Nullable[T] + B Nullable[T] + } + type specs[T primitive] struct { + Name string + Args args[T] + Expected bool + } + stringTests := []specs[string]{ + { + Name: "(string) both valid and equal", + Args: args[string]{ + A: From[string]("Hello"), + B: From[string]("Hello"), + }, + Expected: true, + }, + { + Name: "(string) both invalid", + Args: args[string]{ + A: New[string](), + B: New[string](), + }, + Expected: true, + }, + { + Name: "(string) one valid, one invalid", + Args: args[string]{ + A: From[string]("Hello"), + B: New[string](), + }, + Expected: false, + }, + { + Name: "(string) one valid, one invalid", + Args: args[string]{ + A: New[string](), + B: From[string]("Hello"), + }, + Expected: false, + }, + { + Name: "(string) both valid, not equal", + Args: args[string]{ + A: From[string]("Hello"), + B: From[string]("World"), + }, + Expected: false, + }, + { + Name: "(string) both invalid, not equal data, should be equal", + Args: args[string]{ + A: Nullable[string]{valid: false, data: "Hello"}, + B: Nullable[string]{valid: false, data: "World"}, + }, + Expected: true, + }, + } + intTests := []specs[int]{ + { + Name: "(int) both valid and equal", + Args: args[int]{ + A: From[int](1), + B: From[int](1), + }, + Expected: true, + }, + { + Name: "(int) both invalid", + Args: args[int]{ + A: New[int](), + B: New[int](), + }, + Expected: true, + }, + { + Name: "(int) one valid, one invalid", + Args: args[int]{ + A: From[int](1), + B: New[int](), + }, + Expected: false, + }, + { + Name: "(int) one valid, one invalid", + Args: args[int]{ + A: New[int](), + B: From[int](1), + }, + Expected: false, + }, + { + Name: "(int) both valid, not equal", + Args: args[int]{ + A: From[int](1), + B: From[int](2), + }, + Expected: false, + }, + { + Name: "(int) both invalid, not equal data, should be equal", + Args: args[int]{ + A: Nullable[int]{valid: false, data: 1}, + B: Nullable[int]{valid: false, data: 2}, + }, + Expected: true, + }, + } + boolTests := []specs[bool]{ + { + Name: "(bool) both valid and equal", + Args: args[bool]{ + A: From[bool](true), + B: From[bool](true), + }, + Expected: true, + }, + { + Name: "(bool) both invalid", + Args: args[bool]{ + A: New[bool](), + B: New[bool](), + }, + Expected: true, + }, + { + Name: "(bool) one valid, one invalid", + Args: args[bool]{ + A: From[bool](true), + B: New[bool](), + }, + Expected: false, + }, + { + Name: "(bool) one valid, one invalid", + Args: args[bool]{ + A: New[bool](), + B: From[bool](true), + }, + Expected: false, + }, + { + Name: "(bool) both valid, not equal", + Args: args[bool]{ + A: From[bool](true), + B: From[bool](false), + }, + Expected: false, + }, + { + Name: "(bool) both invalid, not equal data, should be equal", + Args: args[bool]{ + A: Nullable[bool]{valid: false, data: true}, + B: Nullable[bool]{valid: false, data: false}, + }, + Expected: true, + }, + } + float64Tests := []specs[float64]{ + { + Name: "(float64) both valid and equal", + Args: args[float64]{ + A: From[float64](1.23), + B: From[float64](1.23), + }, + Expected: true, + }, + { + Name: "(float64) both invalid", + Args: args[float64]{ + A: New[float64](), + B: New[float64](), + }, + Expected: true, + }, + { + Name: "(float64) one valid, one invalid", + Args: args[float64]{ + A: From[float64](1.23), + B: New[float64](), + }, + Expected: false, + }, + { + Name: "(float64) one valid, one invalid", + Args: args[float64]{ + A: New[float64](), + B: From[float64](1.23), + }, + Expected: false, + }, + { + Name: "(float64) both valid, not equal", + Args: args[float64]{ + A: From[float64](1.23), + B: From[float64](2.45), + }, + Expected: false, + }, + { + Name: "(float64) both invalid, not equal data, should be equal", + Args: args[float64]{ + A: Nullable[float64]{valid: false, data: 1.23}, + B: Nullable[float64]{valid: false, data: 2.45}, + }, + Expected: true, + }, + } + for _, test := range stringTests { + t.Run(test.Name, func(t *testing.T) { + assert.Equal(t, test.Expected, test.Args.A.Equal(test.Args.B)) + }) + } + for _, test := range intTests { + t.Run(test.Name, func(t *testing.T) { + assert.Equal(t, test.Expected, test.Args.A.Equal(test.Args.B)) + }) + } + for _, test := range boolTests { + t.Run(test.Name, func(t *testing.T) { + assert.Equal(t, test.Expected, test.Args.A.Equal(test.Args.B)) + }) + } + for _, test := range float64Tests { + t.Run(test.Name, func(t *testing.T) { + assert.Equal(t, test.Expected, test.Args.A.Equal(test.Args.B)) + }) + } +} + +func TestMarshalJSON(t *testing.T) { + type args struct { + Str Nullable[string] + Int Nullable[int] + Bool Nullable[bool] + Float Nullable[float64] + } + type specs struct { + Name string + Args args + Expected []byte + } + + tests := []specs{ + { + Name: "normal string", + Expected: []byte(`{"Str":"Hello","Int":null,"Bool":null,"Float":null}`), + Args: args{ + Str: From[string]("Hello"), + }, + }, + { + Name: "zero-value string", + Expected: []byte(`{"Str":"","Int":null,"Bool":null,"Float":null}`), + Args: args{ + Str: Zero[string](), + }, + }, + { + Name: "null string", + Expected: []byte(`{"Str":null,"Int":null,"Bool":null,"Float":null}`), + Args: args{ + Str: New[string](), + }, + }, + { + Name: "normal int", + Expected: []byte(`{"Str":null,"Int":5,"Bool":null,"Float":null}`), + Args: args{ + Int: From[int](5), + }, + }, + { + Name: "zero-value int", + Expected: []byte(`{"Str":null,"Int":0,"Bool":null,"Float":null}`), + Args: args{ + Int: Zero[int](), + }, + }, + { + Name: "null int", + Expected: []byte(`{"Str":null,"Int":null,"Bool":null,"Float":null}`), + Args: args{ + Int: New[int](), + }, + }, + { + Name: "normal bool", + Expected: []byte(`{"Str":null,"Int":null,"Bool":true,"Float":null}`), + Args: args{ + Bool: From[bool](true), + }, + }, + { + Name: "zero-value bool", + Expected: []byte(`{"Str":null,"Int":null,"Bool":false,"Float":null}`), + Args: args{ + Bool: Zero[bool](), + }, + }, + { + Name: "null bool", + Expected: []byte(`{"Str":null,"Int":null,"Bool":null,"Float":null}`), + Args: args{ + Bool: New[bool](), + }, + }, + { + Name: "normal float", + Expected: []byte(`{"Str":null,"Int":null,"Bool":null,"Float":0.15235}`), + Args: args{ + Float: From[float64](0.15235), + }, + }, + { + Name: "zero-value float", + Expected: []byte(`{"Str":null,"Int":null,"Bool":null,"Float":0}`), + Args: args{ + Float: Zero[float64](), + }, + }, + { + Name: "null float", + Expected: []byte(`{"Str":null,"Int":null,"Bool":null,"Float":null}`), + Args: args{ + Float: New[float64](), + }, + }, + } + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got, err := json.Marshal(test.Args) + assert.NoError(t, err) + assert.Truef(t, bytes.EqualFold(got, test.Expected), "MarshalJSON() got = %s, want %s", got, test.Expected) + }) + } + + // test error +} + +func TestUnmarshalJSON(t *testing.T) { + type args struct { + Str Nullable[string] + Int Nullable[int] + Bool Nullable[bool] + Float Nullable[float64] + } + type specs struct { + Name string + JSON string + Expected args + } + isEqualArgs := func(a, b args) bool { + return a.Str.Equal(b.Str) && a.Int.Equal(b.Int) && a.Bool.Equal(b.Bool) && a.Float.Equal(b.Float) + } + + tests := []specs{ + { + Name: "normal string", + JSON: `{"Str":"Hello"}`, + Expected: args{ + Str: From[string]("Hello"), + }, + }, + { + Name: "zero-value string", + JSON: `{"Str":""}`, + Expected: args{ + Str: Zero[string](), + }, + }, + { + Name: "null string", + JSON: `{"Str":null}`, + Expected: args{ + Str: New[string](), + }, + }, + { + Name: "normal int", + JSON: `{"Int":5}`, + Expected: args{ + Int: From[int](5), + }, + }, + { + Name: "zero-value int", + JSON: `{"Int":0}`, + Expected: args{ + Int: Zero[int](), + }, + }, + { + Name: "null int", + JSON: `{"Int":null}`, + Expected: args{ + Int: New[int](), + }, + }, + { + Name: "normal bool", + JSON: `{"Bool":true}`, + Expected: args{ + Bool: From[bool](true), + }, + }, + { + Name: "zero-value bool", + JSON: `{"Bool":false}`, + Expected: args{ + Bool: Zero[bool](), + }, + }, + { + Name: "null bool", + JSON: `{"Bool":null}`, + Expected: args{ + Bool: New[bool](), + }, + }, + { + Name: "normal float", + JSON: `{"Float":0.15235}`, + Expected: args{ + Float: From[float64](0.15235), + }, + }, + { + Name: "zero-value float", + JSON: `{"Float":0}`, + Expected: args{ + Float: Zero[float64](), + }, + }, + { + Name: "null float", + JSON: `{"Float":null}`, + Expected: args{ + Float: New[float64](), + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + var got args + assert.NoError(t, json.Unmarshal([]byte(test.JSON), &got)) + assert.Truef(t, isEqualArgs(got, test.Expected), "UnmarshalJSON() got = %v, want %v", got, test.Expected) + }) + } + + // test error + t.Run("unmarshal error", func(t *testing.T) { + payload := []byte(`{"Str":true}`) + var got args + assert.Error(t, json.Unmarshal(payload, &got)) + }) +} From d18934631414a6c870f376bcc401de1ab736f477 Mon Sep 17 00:00:00 2001 From: Nut Pinyo Date: Thu, 16 Nov 2023 20:37:03 +0700 Subject: [PATCH 2/3] feat: add nullable to README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 97354c6..476ded2 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ Minimalist and zero-dependency errors library with stacktrace support for Go (fo [See here](errors/README.md). +## nullable + +A safe way to represent nullable primitive values in Go. Supports JSON serialization. + +[See here](nullable/README.md). + ## queue Minimalist and zero-dependency low-level and simple queue library for thread-safe and unlimited-size generics in-memory message queue library for Go (async enqueue and blocking dequeue supports).\ From 572095fb5140dab679efeca6789e19e172bf9af2 Mon Sep 17 00:00:00 2001 From: Nut Pinyo Date: Thu, 16 Nov 2023 22:10:36 +0700 Subject: [PATCH 3/3] feat: export Primitive --- nullable/nullable.go | 15 ++++++++------- nullable/nullable_test.go | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/nullable/nullable.go b/nullable/nullable.go index 3a064e6..8cda39c 100644 --- a/nullable/nullable.go +++ b/nullable/nullable.go @@ -8,10 +8,11 @@ import ( "github.com/Cleverse/go-utilities/errors" ) -type primitive interface { +// Primitive is a type constraint for all primitive types, except pointers, slices, maps, channels and structs. +type Primitive interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | - ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | - ~float32 | ~float64 | ~bool | ~string + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~float32 | ~float64 | ~bool | ~string } var nullBytes = []byte("null") @@ -19,18 +20,18 @@ var nullBytes = []byte("null") // Nullable is a generic type that can be used to represent a nullable value. If valid is true, then data is considered non-null. // If valid is false, then data is considered null. Nullable supports all primitive types, except pointers, slices, maps, channels and structs. // Nullable supports -type Nullable[T primitive] struct { +type Nullable[T Primitive] struct { valid bool data T } // New returns a new null Nullable. -func New[T primitive]() Nullable[T] { +func New[T Primitive]() Nullable[T] { return Nullable[T]{} } // From returns a non-null Nullable with the given data. -func From[T primitive](data T) Nullable[T] { +func From[T Primitive](data T) Nullable[T] { return Nullable[T]{ valid: true, data: data, @@ -38,7 +39,7 @@ func From[T primitive](data T) Nullable[T] { } // Zero returns a non-null Nullable with the zero value of the given type. -func Zero[T primitive]() Nullable[T] { +func Zero[T Primitive]() Nullable[T] { return Nullable[T]{ valid: true, } diff --git a/nullable/nullable_test.go b/nullable/nullable_test.go index c3049de..c04c414 100644 --- a/nullable/nullable_test.go +++ b/nullable/nullable_test.go @@ -9,11 +9,11 @@ import ( ) func TestEqual(t *testing.T) { - type args[T primitive] struct { + type args[T Primitive] struct { A Nullable[T] B Nullable[T] } - type specs[T primitive] struct { + type specs[T Primitive] struct { Name string Args args[T] Expected bool