Compare commits

...

2 Commits

Author SHA1 Message Date
Bjørn Erik Pedersen f31dc0aaab
Adjust timezone logic
This commit adjusts the previous commit re. the new `ToTimeInDefaultLocationE` and related functions.

The functional change is that we don't default to any location if not provided from the caller.
This is in line with how `ToTime` worked before we started this, and even if the default behaviour may look weird in some cases, it will not break anything.

Most applications will want to use the new *InDefaultLocation functions and decide which default location to use:

```go
loc := time.Local
if config.Location != "" {
    loc = time.LoadLocation(config.Location)
}

t, err := StringToDateInDefaultLocation("2019-01-01", loc)

```

This commit also configure Travis to test on OSX and Windows in addition to Linux.
2019-05-31 17:19:31 +02:00
Heewa Barfchin b1aa5f0c52
Add ToTimeInDefaultLocation/E
Go's time parsing uses UTC when the format doesn't have a tiemzone, and
has even weirder behavior when it has a zone name but no numeric offset.
A caller to `cast.ToTime` won't know if the returned time was explicitly
in UTC, or defaulted there, so the caller cannot fix it. These new
functions allow a user to supply a different timezone to default to,
with nil using the local zone.
2019-05-31 11:32:53 +02:00
5 changed files with 256 additions and 41 deletions

View File

@ -8,9 +8,14 @@ go:
- tip - tip
os: os:
- linux - linux
- osx
- windows
matrix: matrix:
allow_failures: allow_failures:
- go: tip - go: tip
exclude:
- os: windows
go: tip
fast_finish: true fast_finish: true
script: script:
- make check - if [[ "$TRAVIS_OS_NAME" == "windows" ]]; then go test -v -race ./...; else make check; fi

View File

@ -1,15 +1,15 @@
GOVERSION := $(shell go version | cut -d ' ' -f 3 | cut -d '.' -f 2) GOVERSION := $(shell go version | cut -d ' ' -f 3 | cut -d '.' -f 2)
.PHONY: check fmt lint test test-race vet test-cover-html help .PHONY: check fmt test test-race vet test-cover-html help
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
check: test-race fmt vet lint ## Run tests and linters check: test-race fmt vet ## Run tests and linters
test: ## Run tests test: ## Run tests
go test ./... go test ./...
test-race: ## Run tests with race detector test-race: ## Run tests with race detector
go test -race ./... go test -v -race ./...
fmt: ## Run gofmt linter fmt: ## Run gofmt linter
ifeq "$(GOVERSION)" "12" ifeq "$(GOVERSION)" "12"
@ -20,12 +20,6 @@ ifeq "$(GOVERSION)" "12"
done done
endif endif
lint: ## Run golint linter
@for d in `go list` ; do \
if [ "`golint $$d | tee /dev/stderr`" ]; then \
echo "^ golint errors!" && echo && exit 1; \
fi \
done
vet: ## Run go vet linter vet: ## Run go vet linter
@if [ "`go vet | tee /dev/stderr`" ]; then \ @if [ "`go vet | tee /dev/stderr`" ]; then \

View File

@ -20,6 +20,14 @@ func ToTime(i interface{}) time.Time {
return v return v
} }
// ToTimeInDefaultLocationE casts an empty interface to time.Time,
// interpreting inputs without a timezone to be in the given location.
// To fall back to the local timezone, use time.Local as the last argument.
func ToTimeInDefaultLocation(i interface{}, location *time.Location) time.Time {
v, _ := ToTimeInDefaultLocationE(i, location)
return v
}
// ToDuration casts an interface to a time.Duration type. // ToDuration casts an interface to a time.Duration type.
func ToDuration(i interface{}) time.Duration { func ToDuration(i interface{}) time.Duration {
v, _ := ToDurationE(i) v, _ := ToDurationE(i)

View File

@ -8,10 +8,12 @@ package cast
import ( import (
"fmt" "fmt"
"html/template" "html/template"
"path"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestToUintE(t *testing.T) { func TestToUintE(t *testing.T) {
@ -1173,7 +1175,7 @@ func TestIndirectPointers(t *testing.T) {
assert.Equal(t, ToInt(z), 13) assert.Equal(t, ToInt(z), 13)
} }
func TestToTimeEE(t *testing.T) { func TestToTime(t *testing.T) {
tests := []struct { tests := []struct {
input interface{} input interface{}
expect time.Time expect time.Time
@ -1285,3 +1287,148 @@ func TestToDurationE(t *testing.T) {
assert.Equal(t, test.expect, v, errmsg) assert.Equal(t, test.expect, v, errmsg)
} }
} }
func TestToTimeWithTimezones(t *testing.T) {
est, err := time.LoadLocation("EST")
require.NoError(t, err)
irn, err := time.LoadLocation("Iran")
require.NoError(t, err)
require.NoError(t, err)
// Test same local time in different timezones
utc2016 := time.Date(2016, time.January, 1, 3, 1, 0, 0, time.UTC)
est2016 := time.Date(2016, time.January, 1, 3, 1, 0, 0, est)
irn2016 := time.Date(2016, time.January, 1, 3, 1, 0, 0, irn)
loc2016 := time.Date(2016, time.January, 1, 3, 1, 0, 0, time.Local)
for i, format := range timeFormats {
format := format
if format.typ == timeFormatShort {
continue
}
nameBase := fmt.Sprintf("%d;timeFormatType=%d;%s", i, format.typ, format.format)
t.Run(path.Join(nameBase), func(t *testing.T) {
est2016str := est2016.Format(format.format)
loc2016str := loc2016.Format(format.format)
t.Run("without default location", func(t *testing.T) {
assert := require.New(t)
converted, err := ToTimeE(est2016str)
assert.NoError(err)
if format.hasNumericTimezone() {
assertTimeEqual(t, est2016, converted)
assertLocationEqual(t, est, converted.Location())
} else {
assertTimeEqual(t, utc2016, converted)
assertLocationEqual(t, time.UTC, converted.Location())
}
})
t.Run("local timezone without a default location", func(t *testing.T) {
assert := require.New(t)
converted, err := ToTimeE(loc2016str)
assert.NoError(err)
if format.hasAnyTimezone() {
// Local timezone strings can be either named or numeric and
// time.Parse connects the dots.
assertTimeEqual(t, loc2016, converted)
assertLocationEqual(t, time.Local, converted.Location())
} else {
assertTimeEqual(t, utc2016, converted)
assertLocationEqual(t, time.UTC, converted.Location())
}
})
t.Run("nil default location", func(t *testing.T) {
assert := require.New(t)
converted, err := ToTimeInDefaultLocationE(est2016str, nil)
assert.NoError(err)
if format.hasNumericTimezone() {
assertTimeEqual(t, est2016, converted)
assertLocationEqual(t, est, converted.Location())
} else {
assertTimeEqual(t, utc2016, converted)
assertLocationEqual(t, time.UTC, converted.Location())
}
})
t.Run("default location not UTC", func(t *testing.T) {
assert := require.New(t)
converted, err := ToTimeInDefaultLocationE(est2016str, irn)
assert.NoError(err)
if format.hasNumericTimezone() {
assertTimeEqual(t, est2016, converted)
assertLocationEqual(t, est, converted.Location())
} else {
assertTimeEqual(t, irn2016, converted)
assertLocationEqual(t, irn, converted.Location())
}
})
t.Run("time in the local timezone default location not UTC", func(t *testing.T) {
assert := require.New(t)
converted, err := ToTimeInDefaultLocationE(loc2016str, irn)
assert.NoError(err)
if format.hasNumericTimezone() {
assertTimeEqual(t, loc2016, converted)
assertLocationEqual(t, time.Local, converted.Location())
} else {
assertTimeEqual(t, irn2016, converted)
assertLocationEqual(t, irn, converted.Location())
}
})
})
}
}
func assertTimeEqual(t *testing.T, expected, actual time.Time) {
t.Helper()
require.True(t, expected.Equal(actual), fmt.Sprintf("expected\n%s\ngot\n%s", expected, actual))
format := "2006-01-02 15:04:05.999999999 -0700"
require.Equal(t, expected.Format(format), actual.Format(format))
}
func assertLocationEqual(t *testing.T, expected, actual *time.Location) {
t.Helper()
require.True(t, locationEqual(expected, actual), fmt.Sprintf("Expected location '%s', got '%s'", expected, actual))
}
func locationEqual(a, b *time.Location) bool {
// A note about comparing time.Locations:
// - can't only compare pointers
// - can't compare loc.String() because locations with the same
// name can have different offsets
// - can't use reflect.DeepEqual because time.Location has internal
// caches
if a == b {
return true
} else if a == nil || b == nil {
return false
}
// Check if they're equal by parsing times with a format that doesn't
// include a timezone, which will interpret it as being a local time in
// the given zone, and comparing the resulting local times.
tA, err := time.ParseInLocation("2006-01-02", "2016-01-01", a)
if err != nil {
return false
}
tB, err := time.ParseInLocation("2006-01-02", "2016-01-01", b)
if err != nil {
return false
}
return tA.Equal(tB)
}

121
caste.go
View File

@ -20,13 +20,20 @@ var errNegativeNotAllowed = errors.New("unable to cast negative value")
// ToTimeE casts an interface to a time.Time type. // ToTimeE casts an interface to a time.Time type.
func ToTimeE(i interface{}) (tim time.Time, err error) { func ToTimeE(i interface{}) (tim time.Time, err error) {
return ToTimeInDefaultLocationE(i, nil)
}
// ToTimeInDefaultLocationE casts an empty interface to time.Time,
// interpreting inputs without a timezone to be in the given location.
// To fall back to the local timezone, use time.Local as the last argument.
func ToTimeInDefaultLocationE(i interface{}, location *time.Location) (tim time.Time, err error) {
i = indirect(i) i = indirect(i)
switch v := i.(type) { switch v := i.(type) {
case time.Time: case time.Time:
return v, nil return v, nil
case string: case string:
return StringToDate(v) return StringToDateInDefaultLocation(v, location)
case int: case int:
return time.Unix(int64(v), 0), nil return time.Unix(int64(v), 0), nil
case int64: case int64:
@ -1204,43 +1211,97 @@ func ToDurationSliceE(i interface{}) ([]time.Duration, error) {
// predefined list of formats. If no suitable format is found, an error is // predefined list of formats. If no suitable format is found, an error is
// returned. // returned.
func StringToDate(s string) (time.Time, error) { func StringToDate(s string) (time.Time, error) {
return parseDateWith(s, []string{ return parseDateWith(s, nil, timeFormats)
time.RFC3339, }
"2006-01-02T15:04:05", // iso8601 without timezone
time.RFC1123Z, // StringToDateInDefaultLocation to parse a string into a time.Time type using a
time.RFC1123, // predefined list of formats, interpreting inputs without a timezone to be in
time.RFC822Z, // the given location.
time.RFC822, // To fall back to the local timezone, use time.Local as the last argument.
time.RFC850, func StringToDateInDefaultLocation(s string, location *time.Location) (time.Time, error) {
time.ANSIC, return parseDateWith(s, location, timeFormats)
time.UnixDate, }
time.RubyDate,
"2006-01-02 15:04:05.999999999 -0700 MST", // Time.String() func parseDateWith(s string, location *time.Location, formats []timeFormat) (d time.Time, e error) {
"2006-01-02", for _, format := range formats {
"02 Jan 2006", if d, e = time.Parse(format.format, s); e == nil {
"2006-01-02T15:04:05-0700", // RFC3339 without timezone hh:mm colon
"2006-01-02 15:04:05 -07:00", // Some time formats have a zone name, but no offset, so it gets
"2006-01-02 15:04:05 -0700", // put in that zone name (not the default one passed in to us), but
"2006-01-02 15:04:05Z07:00", // RFC3339 without T // without that zone's offset. So set the location manually.
"2006-01-02 15:04:05Z0700", // RFC3339 without T or timezone hh:mm colon // Note that we only do this when we get a location in the new *InDefaultLocation
"2006-01-02 15:04:05", // variants to avoid breaking existing behaviour in ToTime, however
time.Kitchen, // weird that existing behaviour may be.
time.Stamp, if location != nil && !format.hasNumericTimezone() {
time.StampMilli, year, month, day := d.Date()
time.StampMicro, hour, min, sec := d.Clock()
time.StampNano, d = time.Date(year, month, day, hour, min, sec, d.Nanosecond(), location)
})
} }
func parseDateWith(s string, dates []string) (d time.Time, e error) {
for _, dateType := range dates {
if d, e = time.Parse(dateType, s); e == nil {
return return
} }
} }
return d, fmt.Errorf("unable to parse date: %s", s) return d, fmt.Errorf("unable to parse date: %s", s)
} }
type timeFormatType int
const (
timeFormatShort timeFormatType = iota // time or date only, no timezone
timeFormatNoTimezone
// All below have some kind of timezone information, a name and/or offset.
timeFormatNamedTimezone
// All below have what we consider to be solid timezone information.
timeFormatNumericAndNamedTimezone
timeFormatNumericTimezone
)
type timeFormat struct {
format string
typ timeFormatType
}
func (f timeFormat) hasNumericTimezone() bool {
return f.typ >= timeFormatNumericAndNamedTimezone
}
func (f timeFormat) hasAnyTimezone() bool {
return f.typ >= timeFormatNamedTimezone
}
var (
timeFormats = []timeFormat{
{time.RFC3339, timeFormatNumericTimezone},
{"2006-01-02T15:04:05", timeFormatNoTimezone}, // iso8601 without timezone
{time.RFC1123Z, timeFormatNumericTimezone},
{time.RFC1123, timeFormatNamedTimezone},
{time.RFC822Z, timeFormatNumericTimezone},
{time.RFC822, timeFormatNamedTimezone},
{time.RFC850, timeFormatNamedTimezone},
{"2006-01-02 15:04:05.999999999 -0700 MST", timeFormatNumericAndNamedTimezone}, // Time.String()
{"2006-01-02T15:04:05-0700", timeFormatNumericTimezone}, // RFC3339 without timezone hh:mm colon
{"2006-01-02 15:04:05Z0700", timeFormatNumericTimezone}, // RFC3339 without T or timezone hh:mm colon
{"2006-01-02 15:04:05", timeFormatNoTimezone},
{time.ANSIC, timeFormatNoTimezone},
// Must try RubyDate before UnixDate, see:
// https://github.com/golang/go/issues/32358
{time.RubyDate, timeFormatNumericTimezone},
{time.UnixDate, timeFormatNamedTimezone},
{"2006-01-02 15:04:05Z07:00", timeFormatNumericTimezone},
{"2006-01-02", timeFormatShort},
{"02 Jan 2006", timeFormatShort},
{"2006-01-02 15:04:05 -07:00", timeFormatNumericTimezone},
{"2006-01-02 15:04:05 -0700", timeFormatNumericTimezone},
{time.Kitchen, timeFormatShort},
{time.Stamp, timeFormatShort},
{time.StampMilli, timeFormatShort},
{time.StampMicro, timeFormatShort},
{time.StampNano, timeFormatShort},
}
)
// jsonStringToObject attempts to unmarshall a string as JSON into // jsonStringToObject attempts to unmarshall a string as JSON into
// the object passed as pointer. // the object passed as pointer.
func jsonStringToObject(s string, v interface{}) error { func jsonStringToObject(s string, v interface{}) error {