diff --git a/.travis.yml b/.travis.yml index 833a487..c251807 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,9 +8,14 @@ go: - tip os: - linux + - osx + - windows matrix: allow_failures: - go: tip + exclude: + - os: windows + go: tip fast_finish: true script: - - make check + - if [[ "$TRAVIS_OS_NAME" == "windows" ]]; then go test -v -race ./...; else make check; fi diff --git a/Makefile b/Makefile index f01a5db..74629c2 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,15 @@ 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 -check: test-race fmt vet lint ## Run tests and linters +check: test-race fmt vet ## Run tests and linters test: ## Run tests go test ./... test-race: ## Run tests with race detector - go test -race ./... + go test -v -race ./... fmt: ## Run gofmt linter ifeq "$(GOVERSION)" "12" @@ -20,12 +20,6 @@ ifeq "$(GOVERSION)" "12" done 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 @if [ "`go vet | tee /dev/stderr`" ]; then \ diff --git a/cast.go b/cast.go index 9fba638..58f674f 100644 --- a/cast.go +++ b/cast.go @@ -20,6 +20,14 @@ func ToTime(i interface{}) time.Time { 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. func ToDuration(i interface{}) time.Duration { v, _ := ToDurationE(i) diff --git a/cast_test.go b/cast_test.go index d9a1479..2c83fdb 100644 --- a/cast_test.go +++ b/cast_test.go @@ -8,10 +8,12 @@ package cast import ( "fmt" "html/template" + "path" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestToUintE(t *testing.T) { @@ -1173,7 +1175,7 @@ func TestIndirectPointers(t *testing.T) { assert.Equal(t, ToInt(z), 13) } -func TestToTimeEE(t *testing.T) { +func TestToTime(t *testing.T) { tests := []struct { input interface{} expect time.Time @@ -1285,3 +1287,148 @@ func TestToDurationE(t *testing.T) { 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) +} diff --git a/caste.go b/caste.go index 70c7291..c1fe933 100644 --- a/caste.go +++ b/caste.go @@ -20,13 +20,20 @@ var errNegativeNotAllowed = errors.New("unable to cast negative value") // ToTimeE casts an interface to a time.Time type. 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) switch v := i.(type) { case time.Time: return v, nil case string: - return StringToDate(v) + return StringToDateInDefaultLocation(v, location) case int: return time.Unix(int64(v), 0), nil 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 // returned. func StringToDate(s string) (time.Time, error) { - return parseDateWith(s, []string{ - time.RFC3339, - "2006-01-02T15:04:05", // iso8601 without timezone - time.RFC1123Z, - time.RFC1123, - time.RFC822Z, - time.RFC822, - time.RFC850, - time.ANSIC, - time.UnixDate, - time.RubyDate, - "2006-01-02 15:04:05.999999999 -0700 MST", // Time.String() - "2006-01-02", - "02 Jan 2006", - "2006-01-02T15:04:05-0700", // RFC3339 without timezone hh:mm colon - "2006-01-02 15:04:05 -07:00", - "2006-01-02 15:04:05 -0700", - "2006-01-02 15:04:05Z07:00", // RFC3339 without T - "2006-01-02 15:04:05Z0700", // RFC3339 without T or timezone hh:mm colon - "2006-01-02 15:04:05", - time.Kitchen, - time.Stamp, - time.StampMilli, - time.StampMicro, - time.StampNano, - }) + return parseDateWith(s, nil, timeFormats) } -func parseDateWith(s string, dates []string) (d time.Time, e error) { - for _, dateType := range dates { - if d, e = time.Parse(dateType, s); e == nil { +// StringToDateInDefaultLocation to parse a string into a time.Time type using a +// predefined list of formats, 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 StringToDateInDefaultLocation(s string, location *time.Location) (time.Time, error) { + return parseDateWith(s, location, timeFormats) +} + +func parseDateWith(s string, location *time.Location, formats []timeFormat) (d time.Time, e error) { + for _, format := range formats { + if d, e = time.Parse(format.format, s); e == nil { + + // Some time formats have a zone name, but no offset, so it gets + // put in that zone name (not the default one passed in to us), but + // without that zone's offset. So set the location manually. + // Note that we only do this when we get a location in the new *InDefaultLocation + // variants to avoid breaking existing behaviour in ToTime, however + // weird that existing behaviour may be. + if location != nil && !format.hasNumericTimezone() { + year, month, day := d.Date() + hour, min, sec := d.Clock() + d = time.Date(year, month, day, hour, min, sec, d.Nanosecond(), location) + } + return } } 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 // the object passed as pointer. func jsonStringToObject(s string, v interface{}) error {