diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f8eeb1d --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +format: + @go install mvdan.cc/gofumpt@latest + gofumpt -l -w -extra . + +lint: + @go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50.1 + golangci-lint run ./... + +test: + go test ./... + +check: format lint test + go mod tidy diff --git a/README.md b/README.md index 891bee6..2fa2650 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ -# tz -golang time zone type +# Golang Time Zone Type +[![Go Reference](https://pkg.go.dev/badge/github.com/min0625/tz.svg)](https://pkg.go.dev/github.com/min0625/tz) + +## Features +- TimeZone based on time.LoadLocation format, Not support the "Local" time zone. +- The format is "UTC" or IANA time zone database name. See: https://www.iana.org/time-zones. +- The zero value means UTC time zone. +- The UTC time zone always uses a zero value. +- Implement the fmt.Stringer interface. +- Implement the sql.Scanner interface. +- Implement the driver.Valuer interface. +- Implement the encoding.TextMarshaler interface. +- Implement the encoding.TextUnmarshaler interface. +- Implement the json.Marshaler interface. +- Implement the json.Unmarshaler interface. + +## Installation +```sh +go get github.com/min0625/tz +``` + +## Quick start +```go +package main + +import ( + "fmt" + "time" + _ "time/tzdata" + + "github.com/min0625/tz" +) + +func main() { + z, err := tz.LoadTimeZone("America/New_York") + if err != nil { + panic(err) + } + + fmt.Println(z.String()) + fmt.Println(time.Time{}.In(z.Location()).Location().String()) + + // Output: + // America/New_York + // America/New_York +} + +``` + +## Example +See: [./time_zone_example_test.go](./time_zone_example_test.go) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..22a7567 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/min0625/tz + +go 1.18 + +require github.com/stretchr/testify v1.8.1 + +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..2ec90f7 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/time_zone.go b/time_zone.go new file mode 100644 index 0000000..468cd59 --- /dev/null +++ b/time_zone.go @@ -0,0 +1,116 @@ +package tz + +import ( + "database/sql" + "database/sql/driver" + "encoding" + "encoding/json" + "errors" + "fmt" + "time" +) + +// TimeZone based on time.LoadLocation format, Not support the "Local" time zone. +// The format is "UTC" or IANA time zone database name. +// See: https://www.iana.org/time-zones. +// The zero value means UTC time zone. +// The UTC time zone always uses a zero value. +type TimeZone struct { + loc *time.Location +} + +var UTCTimeZone = TimeZone{} + +var ( + _ fmt.Stringer = TimeZone{} + _ sql.Scanner = &TimeZone{} + _ driver.Valuer = TimeZone{} + _ encoding.TextMarshaler = TimeZone{} + _ encoding.TextUnmarshaler = &TimeZone{} + _ json.Marshaler = TimeZone{} + _ json.Unmarshaler = &TimeZone{} +) + +func LoadTimeZone(name string) (TimeZone, error) { + var z TimeZone + if err := z.loadString(name); err != nil { + return TimeZone{}, err + } + + return z, nil +} + +// Location always returns a non-nil location. +func (z TimeZone) Location() *time.Location { + if loc := z.loc; loc != nil { + return loc + } + + return time.UTC +} + +func (z *TimeZone) loadString(s string) error { + loc, err := time.LoadLocation(s) + if err != nil { + return err + } + + if loc == time.Local { + return errors.New("invalid TimeZone: Local") + } + + if loc == time.UTC { + loc = nil + } + + z.loc = loc + return nil +} + +func (z TimeZone) String() string { + return z.Location().String() +} + +func (z *TimeZone) Scan(value any) error { + var ns sql.NullString + if err := ns.Scan(value); err != nil { + return err + } + + if !ns.Valid { + return errors.New("converting NULL to TimeZone is unsupported") + } + + return z.loadString(ns.String) +} + +func (z TimeZone) Value() (driver.Value, error) { + return z.String(), nil +} + +func (z TimeZone) MarshalText() (text []byte, err error) { + return []byte(z.String()), nil +} + +func (z *TimeZone) UnmarshalText(text []byte) error { + return z.loadString(string(text)) +} + +func (z TimeZone) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("%q", z.String())), nil +} + +func (z *TimeZone) UnmarshalJSON(data []byte) error { + // Ignore null, like in the main JSON package. + // See: https://pkg.go.dev/encoding/json#Unmarshaler. + if string(data) == "null" { + return nil + } + + var unquote string + if _, err := fmt.Sscanf(string(data), `%q`, &unquote); err != nil { + return err + } + + return z.loadString(unquote) +} diff --git a/time_zone_example_test.go b/time_zone_example_test.go new file mode 100644 index 0000000..2f471a5 --- /dev/null +++ b/time_zone_example_test.go @@ -0,0 +1,70 @@ +package tz_test + +import ( + "fmt" + "time" + _ "time/tzdata" + + "github.com/min0625/tz" +) + +func ExampleTimeZone() { + z, err := tz.LoadTimeZone("America/New_York") + if err != nil { + panic(err) + } + + fmt.Println(z.String()) + fmt.Println(time.Time{}.In(z.Location()).Location().String()) + + // Output: + // America/New_York + // America/New_York +} + +func ExampleTimeZone_zeroValue() { + z, err := tz.LoadTimeZone("UTC") + if err != nil { + panic(err) + } + + fmt.Println(z == tz.TimeZone{}) + + // Output: + // true +} + +func ExampleTimeZone_Scan() { + var z tz.TimeZone + if err := z.Scan("America/New_York"); err != nil { + panic(err) + } + + fmt.Println(time.Time{}.In(z.Location()).Location().String()) + + // Output: + // America/New_York +} + +func ExampleTimeZone_Value() { + z, err := tz.LoadTimeZone("America/New_York") + if err != nil { + panic(err) + } + + v, err := z.Value() + fmt.Printf("%v %T %v\n", v, v, err) + + // Output: + // America/New_York string +} + +func ExampleTimeZone_Value_zeroValue() { + var z tz.TimeZone + + v, err := z.Value() + fmt.Printf("%v %T %v\n", v, v, err) + + // Output: + // UTC string +} diff --git a/time_zone_test.go b/time_zone_test.go new file mode 100644 index 0000000..e95ef32 --- /dev/null +++ b/time_zone_test.go @@ -0,0 +1,492 @@ +package tz + +import ( + "database/sql/driver" + "testing" + "time" + _ "time/tzdata" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustLoadTimeLocation(t *testing.T, name string) *time.Location { + loc, err := time.LoadLocation(name) + require.NoError(t, err) + return loc +} + +func mustLoadTimeZone(t *testing.T, name string) TimeZone { + z, err := LoadTimeZone(name) + require.NoError(t, err) + return z +} + +func TestLoadTimeZone(t *testing.T) { + t.Parallel() + + tests := []struct { + testName string + name string + want TimeZone + wantErr bool + }{ + { + testName: "Empty", + name: "", + want: TimeZone{}, + wantErr: false, + }, + { + testName: "UTC", + name: "UTC", + want: TimeZone{}, + wantErr: false, + }, + { + testName: "Local", + name: "Local", + want: TimeZone{}, + wantErr: true, + }, + { + testName: "America/New_York", + name: "America/New_York", + want: TimeZone{ + loc: mustLoadTimeLocation(t, "America/New_York"), + }, + wantErr: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.testName, func(t *testing.T) { + t.Parallel() + + got, err := LoadTimeZone(tt.name) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, got, tt.want) + }) + } +} + +func TestTimeZone_Location_ZeroValueReturnUTC(t *testing.T) { + t.Parallel() + assert.Same(t, TimeZone{}.Location(), time.UTC) +} + +func TestTimeZone_loadString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + giveTimeZone TimeZone + data string + wantTimeZone TimeZone + wantErr bool + }{ + { + name: "UTC", + data: "UTC", + wantTimeZone: mustLoadTimeZone(t, "UTC"), + wantErr: false, + }, + { + name: "America/New_York", + data: "America/New_York", + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "Asia/Tokyo", + data: "Asia/Tokyo", + wantTimeZone: mustLoadTimeZone(t, "Asia/Tokyo"), + wantErr: false, + }, + { + name: "America/New_York", + data: "America/New_York", + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "ErrName", + giveTimeZone: mustLoadTimeZone(t, "America/New_York"), + data: "ErrName", + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + z := tt.giveTimeZone + + err := z.loadString(tt.data) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, z, tt.wantTimeZone) + }) + } +} + +func TestTimeZone_String(t *testing.T) { + tests := []struct { + name string + z TimeZone + want string + }{ + { + name: "Empty", + z: mustLoadTimeZone(t, ""), + want: "UTC", + }, + { + name: "UTC", + z: mustLoadTimeZone(t, "UTC"), + want: "UTC", + }, + { + name: "America/New_York", + z: mustLoadTimeZone(t, "America/New_York"), + want: "America/New_York", + }, + { + name: "Asia/Tokyo", + z: mustLoadTimeZone(t, "Asia/Tokyo"), + want: "Asia/Tokyo", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.z.String(), tt.want) + }) + } +} + +func TestTimeZone_Scan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + giveTimeZone TimeZone + value any + wantTimeZone TimeZone + wantErr bool + }{ + { + name: "string_UTC", + value: "UTC", + wantTimeZone: mustLoadTimeZone(t, "UTC"), + wantErr: false, + }, + { + name: "string_America/New_York", + value: "America/New_York", + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "string_Asia/Tokyo", + value: "Asia/Tokyo", + wantTimeZone: mustLoadTimeZone(t, "Asia/Tokyo"), + wantErr: false, + }, + { + name: "bytes_America/New_York", + value: []byte("America/New_York"), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "string_ErrName", + giveTimeZone: mustLoadTimeZone(t, "America/New_York"), + value: "ErrName", + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: true, + }, + { + name: "nil", + giveTimeZone: mustLoadTimeZone(t, "America/New_York"), + value: nil, + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + z := tt.giveTimeZone + + err := z.Scan(tt.value) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, z, tt.wantTimeZone) + }) + } +} + +func TestTimeZone_Value(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + z TimeZone + want driver.Value + wantErr bool + }{ + { + name: "UTC", + z: mustLoadTimeZone(t, "UTC"), + want: "UTC", + wantErr: false, + }, + { + name: "Asia/Tokyo", + z: mustLoadTimeZone(t, "Asia/Tokyo"), + want: "Asia/Tokyo", + wantErr: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.z.Value() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, got, tt.want) + }) + } +} + +func TestTimeZone_MarshalText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + z TimeZone + want []byte + wantErr bool + }{ + { + name: "UTC", + z: mustLoadTimeZone(t, "UTC"), + want: []byte("UTC"), + wantErr: false, + }, + { + name: "Asia/Tokyo", + z: mustLoadTimeZone(t, "Asia/Tokyo"), + want: []byte("Asia/Tokyo"), + wantErr: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.z.MarshalText() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, got, tt.want) + }) + } +} + +func TestTimeZone_UnmarshalText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + giveTimeZone TimeZone + data []byte + wantTimeZone TimeZone + wantErr bool + }{ + { + name: "UTC", + data: []byte("UTC"), + wantTimeZone: mustLoadTimeZone(t, "UTC"), + wantErr: false, + }, + { + name: "America/New_York", + data: []byte("America/New_York"), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "Asia/Tokyo", + data: []byte("Asia/Tokyo"), + wantTimeZone: mustLoadTimeZone(t, "Asia/Tokyo"), + wantErr: false, + }, + { + name: "America/New_York", + data: []byte("America/New_York"), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "ErrName", + giveTimeZone: mustLoadTimeZone(t, "America/New_York"), + data: []byte("ErrName"), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + z := tt.giveTimeZone + + err := z.UnmarshalText(tt.data) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, z, tt.wantTimeZone) + }) + } +} + +func TestTimeZone_MarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + z TimeZone + want []byte + wantErr bool + }{ + { + name: "UTC", + z: mustLoadTimeZone(t, "UTC"), + want: []byte(`"UTC"`), + wantErr: false, + }, + { + name: "Asia/Tokyo", + z: mustLoadTimeZone(t, "Asia/Tokyo"), + want: []byte(`"Asia/Tokyo"`), + wantErr: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.z.MarshalJSON() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, got, tt.want) + }) + } +} + +func TestTimeZone_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + giveTimeZone TimeZone + data []byte + wantTimeZone TimeZone + wantErr bool + }{ + { + name: "UTC", + data: []byte(`"UTC"`), + wantTimeZone: mustLoadTimeZone(t, "UTC"), + wantErr: false, + }, + { + name: "America/New_York", + data: []byte(`"America/New_York"`), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "Asia/Tokyo", + data: []byte(`"Asia/Tokyo"`), + wantTimeZone: mustLoadTimeZone(t, "Asia/Tokyo"), + wantErr: false, + }, + { + name: "America/New_York", + data: []byte(`"America/New_York"`), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + { + name: "ErrName", + giveTimeZone: mustLoadTimeZone(t, "America/New_York"), + data: []byte(`"ErrName"`), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: true, + }, + { + name: "null", + giveTimeZone: mustLoadTimeZone(t, "America/New_York"), + data: []byte(`null`), + wantTimeZone: mustLoadTimeZone(t, "America/New_York"), + wantErr: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + z := tt.giveTimeZone + + err := z.UnmarshalJSON(tt.data) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, z, tt.wantTimeZone) + }) + } +}