diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..eaa4985 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,26 @@ +jobs: + build: + executor: + name: go/default + tag: '1.21.1' + steps: + - checkout + - go/load-cache + - go/mod-download + - go/save-cache + - go/test: + covermode: atomic + failfast: true + race: true + coverprofile: coverage.txt + - run: + name: Coverage Report + command: | + bash <(curl -s https://codecov.io/bash) +orbs: + go: circleci/go@1.9.0 +version: 2.1 +workflows: + main: + jobs: + - build \ No newline at end of file diff --git a/.github/workflows/commit-validation.yaml b/.github/workflows/commit-validation.yaml new file mode 100644 index 0000000..47969eb --- /dev/null +++ b/.github/workflows/commit-validation.yaml @@ -0,0 +1,13 @@ +name: Commit validation + +on: + [pull_request] + +jobs: + commitsar: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + + - name: Commitsar Action + uses: outillage/commitsar@v0.13.0 \ No newline at end of file diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..155a2b2 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,19 @@ +name: golangci-lint +on: + push: + tags: + - v* + branches: + - master + pull_request: +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 + with: + # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. + version: v1.29 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1972613 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,36 @@ +name: Release + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Action For Semantic Release + uses: cycjimmy/semantic-release-action@v2.3.0 + id: semantic + with: + semantic_version: 17 + extra_plugins: | + conventional-changelog-conventionalcommits + @semantic-release/changelog + @semantic-release/git + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Push updates to branch for major version + if: steps.semantic.outputs.new_release_published == 'true' + run: "git push https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git HEAD:refs/heads/v${{steps.semantic.outputs.new_release_major_version}}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6a943f --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +coverage.txt +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8b8baf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Maksim Martianov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..21290f1 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +[![CircleCI](https://dl.circleci.com/status-badge/img/gh/maksimru/go-option/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/maksimru/go-option/tree/master) +[![codecov](https://codecov.io/gh/maksimru/go-option/graph/badge.svg?token=NQICPHBEUQ)](https://codecov.io/gh/maksimru/go-option) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/maksimru/go-option)](https://pkg.go.dev/github.com/maksimru/go-option) +[![Go Report Card](https://goreportcard.com/badge/github.com/maksimru/go-option)](https://goreportcard.com/report/github.com/maksimru/go-option) + +# Go Option + +The idea is based on Scala Option and Java Optional. The package allows to create optional values in Golang + +## Functions + +Set an non-empty value: +``` +option.Some[T](v) +``` + +Set an empty value: +``` +v := option.None[T]() +``` + +Set an empty value if v is nil, otherwise set non-empty value +``` +v := option.NewOption[T](v) +``` + +Remap one option to another option +``` +import "github.com/maksimru/go-option" + +type Car struct { + name string + plateNumber option.Option[string] +} + +carOpt := option.Some[Car]( + Car { + name: "bmw" + }, +) + +// get car name as option +carNameOpt := option.Map[Car, string](carOpt, func(c Car) string { + return c.name +}) +``` + +Option composition +``` +import "github.com/maksimru/go-option" + +type Car struct { + name string + plateNumber option.Option[string] +} + +type User struct { + name string + car option.Option[Car] +} + +u := User{ + name: "jake", + car: option.Some[Car]( + Car{ + name: "bmw", + plateNumber: option.Some[string]("X723"), + }, + ), +} + +// get car plate as option +carPlateOpt := option.FlatMap[Car, string](u.car, func(c Car) option.Option[string] { + return c.plateNumber +}) +``` + +## Methods of Option + +| Method | Description | +|--------|:-----------------------------------------------:| +| Get() | gets underlying value (unsafe*) | +| GetOrElse(fallback) | gets underlying value or returns fallback value | +| OrElse(fallbackOpt) | returns fallback option if option is empty | +| Empty() | checks if value is empty | +| NonEmpty() | checks if value is set | +| String() | string representation | +`* - empty value will panic` + +--- +## Testing + +To run all tests in this module: + +``` +go test ./... +``` diff --git a/config.go b/config.go new file mode 100644 index 0000000..774a651 --- /dev/null +++ b/config.go @@ -0,0 +1 @@ +package option diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b7f4a30 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/maksimru/go-option + +go 1.21.1 + +require 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/go.sum b/go.sum new file mode 100644 index 0000000..fa4b6e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +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/none.go b/none.go new file mode 100644 index 0000000..6fbe765 --- /dev/null +++ b/none.go @@ -0,0 +1,28 @@ +package option + +type optNone[T any] struct { +} + +func (n optNone[T]) Get() T { + panic("called Get on optNone value") +} + +func (n optNone[T]) GetOrElse(v T) T { + return v +} + +func (n optNone[T]) OrElse(opt Option[T]) Option[T] { + return opt +} + +func (n optNone[T]) Empty() bool { + return true +} + +func (n optNone[T]) NonEmpty() bool { + return false +} + +func (n optNone[T]) String() string { + return "None" +} diff --git a/none_test.go b/none_test.go new file mode 100644 index 0000000..bec4b3b --- /dev/null +++ b/none_test.go @@ -0,0 +1,116 @@ +package option + +import ( + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +func TestNone_Empty(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {name: "optNone[T] Empty() returns true", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := optNone[int]{} + if got := n.Empty(); got != tt.want { + t.Errorf("Empty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNone_Get(t *testing.T) { + tests := []struct { + name string + want int + }{ + {name: "optNone[T] Get throws an exception"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := optNone[int]{} + assert.Panics(t, func() { n.Get() }) + }) + } +} + +func TestNone_GetOrElse(t *testing.T) { + type args struct { + v int + } + tests := []struct { + name string + args args + want int + }{ + {name: "optNone[T] GetOrElse() returns else value", args: args{v: 2}, want: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := optNone[int]{} + if got := n.GetOrElse(tt.args.v); !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetOrElse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNone_NonEmpty(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {name: "optNone[T] Empty() returns false", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := optNone[int]{} + if got := n.NonEmpty(); got != tt.want { + t.Errorf("NonEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNone_OrElse(t *testing.T) { + type args struct { + opt Option[int] + } + tests := []struct { + name string + args args + want Option[int] + }{ + {name: "optNone[T] OrElse() returns optNone if else condition is optNone", args: args{opt: optNone[int]{}}, want: optNone[int]{}}, + {name: "optNone[T] OrElse() returns optSome if else condition is optSome", args: args{opt: optSome[int]{2}}, want: optSome[int]{2}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := optNone[int]{} + if got := n.OrElse(tt.args.opt); !reflect.DeepEqual(got, tt.want) { + t.Errorf("OrElse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNone_String(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "optNone[T] String() returns None", want: "None"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := optNone[int]{} + if got := n.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/option.go b/option.go new file mode 100644 index 0000000..dd304c9 --- /dev/null +++ b/option.go @@ -0,0 +1,48 @@ +package option + +import "reflect" + +type Option[T any] interface { + Get() T + GetOrElse(v T) T + OrElse(opt Option[T]) Option[T] + Empty() bool + NonEmpty() bool + String() string +} + +func None[T any]() Option[T] { + return optNone[T]{} +} + +func Some[T any](o T) Option[T] { + return NewOption(o) +} + +func NewOption[T any](o T) Option[T] { + if v := reflect.ValueOf(o); (v.Kind() == reflect.Ptr || + v.Kind() == reflect.Interface || + v.Kind() == reflect.Slice || + v.Kind() == reflect.Map || + v.Kind() == reflect.Chan || + v.Kind() == reflect.Func) && v.IsNil() { + return optNone[T]{} + } + return optSome[T]{o} +} + +func Map[T1, T2 any](opt Option[T1], mapper func(T1) T2) Option[T2] { + if opt.NonEmpty() { + return optSome[T2]{mapper(opt.Get())} + } else { + return optNone[T2]{} + } +} + +func FlatMap[T1, T2 any](opt Option[T1], mapper func(T1) Option[T2]) Option[T2] { + if opt.NonEmpty() { + return mapper(opt.Get()) + } else { + return optNone[T2]{} + } +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..12f5188 --- /dev/null +++ b/option_test.go @@ -0,0 +1,130 @@ +package option + +import ( + "reflect" + "strconv" + "testing" +) + +func TestFlatMap(t *testing.T) { + type args struct { + opt Option[Option[bool]] + mapper func(Option[bool]) Option[bool] + } + tests := []struct { + name string + args args + want Option[bool] + }{ + { + name: "FlatMap with optSome[optSome[true]] get converted to optSome[true]", + args: args{ + opt: optSome[Option[bool]]{ + optSome[bool]{true}, + }, + mapper: func(o1 Option[bool]) Option[bool] { + return o1 + }, + }, + want: optSome[bool]{true}, + }, + { + name: "FlatMap with optSome[optNone] get converted to optNone", + args: args{ + opt: optSome[Option[bool]]{ + optNone[bool]{}, + }, + mapper: func(o1 Option[bool]) Option[bool] { + return o1 + }, + }, + want: optNone[bool]{}, + }, + { + name: "FlatMap with optNone[Option[true]] get converted to optNone", + args: args{ + opt: optNone[Option[bool]]{}, + mapper: func(o1 Option[bool]) Option[bool] { + return o1 + }, + }, + want: optNone[bool]{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FlatMap(tt.args.opt, tt.args.mapper); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FlatMap() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMap(t *testing.T) { + type args struct { + opt Option[int] + mapper func(int) string + } + tests := []struct { + name string + args args + want Option[string] + }{ + { + name: "Map with optSome[int] get converted to optSome[string]", + args: args{ + opt: optSome[int]{4}, + mapper: func(i int) string { + return strconv.Itoa(i) + }, + }, + want: optSome[string]{"4"}, + }, + { + name: "Map with optNone[int] get converted to optNone[string]", + args: args{ + opt: optNone[int]{}, + mapper: func(i int) string { + return strconv.Itoa(i) + }, + }, + want: optNone[string]{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Map(tt.args.opt, tt.args.mapper); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Map() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToOption(t *testing.T) { + type args struct { + v map[int]string + } + tests := []struct { + name string + args args + want Option[map[int]string] + }{ + { + name: "NewOption with value is converted to optSome(value)", + args: args{v: map[int]string{}}, + want: optSome[map[int]string]{map[int]string{}}, + }, + { + name: "NewOption without value is converted to optNone", + args: args{v: nil}, + want: optNone[map[int]string]{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewOption(tt.args.v); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewOption() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/release.config.js b/release.config.js new file mode 100644 index 0000000..5ebf4e0 --- /dev/null +++ b/release.config.js @@ -0,0 +1,34 @@ +const releaseNotesGenOptions = { + "preset": "conventionalcommits", +}; + +const gitOptions = { + "assets": [ + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" +}; + +module.exports = { + "dryRun": false, + "branches": [ + '+([0-9])?(.{+([0-9]),x}).x', + 'master', + 'next', + 'next-major', + {name: 'beta', prerelease: true}, + {name: 'alpha', prerelease: true} + ], + "plugins": [ + "@semantic-release/commit-analyzer", + [ + "@semantic-release/release-notes-generator", + releaseNotesGenOptions + ], + "@semantic-release/changelog", + "@semantic-release/github", + [ + "@semantic-release/git", + gitOptions + ] + ] +}; diff --git a/some.go b/some.go new file mode 100644 index 0000000..611b022 --- /dev/null +++ b/some.go @@ -0,0 +1,33 @@ +package option + +import ( + "fmt" +) + +type optSome[T any] struct { + underlyingValue T +} + +func (s optSome[T]) Get() T { + return s.underlyingValue +} + +func (s optSome[T]) GetOrElse(v T) T { + return s.underlyingValue +} + +func (s optSome[T]) OrElse(opt Option[T]) Option[T] { + return s +} + +func (s optSome[T]) Empty() bool { + return false +} + +func (s optSome[T]) NonEmpty() bool { + return true +} + +func (s optSome[T]) String() string { + return fmt.Sprintf("Some(%v)", s.underlyingValue) +} diff --git a/some_test.go b/some_test.go new file mode 100644 index 0000000..c68e5d4 --- /dev/null +++ b/some_test.go @@ -0,0 +1,153 @@ +package option + +import ( + "reflect" + "testing" +) + +func TestSome_Empty(t *testing.T) { + type fields struct { + underlyingValue int + } + tests := []struct { + name string + fields fields + want bool + }{ + {name: "optSome[T] Empty() returns false", fields: struct{ underlyingValue int }{underlyingValue: 2}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := optSome[int]{ + underlyingValue: tt.fields.underlyingValue, + } + if got := s.Empty(); got != tt.want { + t.Errorf("Empty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSome_Get(t *testing.T) { + type fields struct { + underlyingValue int + } + tests := []struct { + name string + fields fields + want int + }{ + {name: "optSome[T] Get returns underlying value", fields: struct{ underlyingValue int }{underlyingValue: 2}, want: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := optSome[int]{ + underlyingValue: tt.fields.underlyingValue, + } + if got := s.Get(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSome_GetOrElse(t *testing.T) { + type fields struct { + underlyingValue int + } + type args struct { + v int + } + tests := []struct { + name string + fields fields + args args + want int + }{ + {name: "optSome[T] GetOrElse() returns underlying value", fields: struct{ underlyingValue int }{underlyingValue: 2}, args: struct{ v int }{v: 3}, want: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := optSome[int]{ + underlyingValue: tt.fields.underlyingValue, + } + if got := s.GetOrElse(tt.args.v); !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetOrElse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSome_NonEmpty(t *testing.T) { + type fields struct { + underlyingValue int + } + tests := []struct { + name string + fields fields + want bool + }{ + {name: "optSome[T] NonEmpty() returns true", fields: struct{ underlyingValue int }{underlyingValue: 2}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := optSome[int]{ + underlyingValue: tt.fields.underlyingValue, + } + if got := s.NonEmpty(); got != tt.want { + t.Errorf("NonEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSome_OrElse(t *testing.T) { + type fields struct { + underlyingValue int + } + type args struct { + opt Option[int] + } + tests := []struct { + name string + fields fields + args args + want Option[int] + }{ + {name: "optSome[T] OrElse() returns original value with another optSome", fields: struct{ underlyingValue int }{underlyingValue: 3}, args: args{opt: optSome[int]{2}}, want: optSome[int]{3}}, + {name: "optSome[T] OrElse() returns original value with another optNone", fields: struct{ underlyingValue int }{underlyingValue: 3}, args: args{opt: optNone[int]{}}, want: optSome[int]{3}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := optSome[int]{ + underlyingValue: tt.fields.underlyingValue, + } + if got := s.OrElse(tt.args.opt); !reflect.DeepEqual(got, tt.want) { + t.Errorf("OrElse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSome_String(t *testing.T) { + type fields struct { + underlyingValue int + } + tests := []struct { + name string + fields fields + want string + }{ + {name: "optSome[T] String() returns Some(T)", fields: struct{ underlyingValue int }{underlyingValue: 2}, want: "Some(2)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := optSome[int]{ + underlyingValue: tt.fields.underlyingValue, + } + if got := s.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +}