From 22b2b540ce2e240dd3565d1238df82136be02051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Thu, 30 May 2019 11:21:02 +0200 Subject: [PATCH] Adjust timezone logic --- cast_test.go | 224 +++++++++++++++++++-------------------- caste.go | 85 +++++++++------ timeformattype_string.go | 27 +++++ 3 files changed, 188 insertions(+), 148 deletions(-) create mode 100644 timeformattype_string.go diff --git a/cast_test.go b/cast_test.go index 1912bcd..93bd4e3 100644 --- a/cast_test.go +++ b/cast_test.go @@ -9,10 +9,12 @@ import ( "errors" "fmt" "html/template" + "path" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestToUintE(t *testing.T) { @@ -1181,7 +1183,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 @@ -1294,143 +1296,139 @@ func TestToDurationE(t *testing.T) { } } -func TestToTime(t *testing.T) { +func TestToTimeWithTimezones(t *testing.T) { + est, err := time.LoadLocation("EST") - if !assert.NoError(t, err) { - return - } + require.NoError(t, err) irn, err := time.LoadLocation("Iran") - if !assert.NoError(t, err) { - return - } + require.NoError(t, err) swd, err := time.LoadLocation("Europe/Stockholm") - if !assert.NoError(t, err) { - return - } - - // time.Parse*() fns handle the target & local timezones being the same - // differently, so make sure we use one of the timezones as local by - // temporarily change it. - if !locationEqual(time.Local, swd) { - var originalLocation *time.Location - originalLocation, time.Local = time.Local, swd - defer func() { - time.Local = originalLocation - }() - } + require.NoError(t, err) // Test same local time in different timezones utc2016 := time.Date(2016, time.January, 1, 0, 0, 0, 0, time.UTC) est2016 := time.Date(2016, time.January, 1, 0, 0, 0, 0, est) irn2016 := time.Date(2016, time.January, 1, 0, 0, 0, 0, irn) swd2016 := time.Date(2016, time.January, 1, 0, 0, 0, 0, swd) + loc2016 := time.Date(2016, time.January, 1, 0, 0, 0, 0, time.Local) - for _, format := range timeFormats { - t.Logf("Checking time format '%s', has timezone: %v", format.format, format.hasTimezone) - - est2016str := est2016.Format(format.format) - if !assert.NotEmpty(t, est2016str) { + for i, format := range timeFormats { + format := format + if format.typ == timeFormatTimeOnly { continue } - swd2016str := swd2016.Format(format.format) - if !assert.NotEmpty(t, swd2016str) { - continue - } + nameBase := fmt.Sprintf("%d;timeFormatType=%d;%s", i, format.typ, format.format) - // Test conversion without a default location - converted, err := ToTimeE(est2016str) - if assert.NoError(t, err) { - if format.hasTimezone { - // Converting inputs with a timezone should preserve it - assertTimeEqual(t, est2016, converted) - assertLocationEqual(t, est, converted.Location()) - } else { - // Converting inputs without a timezone should be interpreted - // as a local time in UTC. - assertTimeEqual(t, utc2016, converted) - assertLocationEqual(t, time.UTC, converted.Location()) - } - } + t.Run(path.Join(nameBase), func(t *testing.T) { + est2016str := est2016.Format(format.format) + swd2016str := swd2016.Format(format.format) - // Test conversion of a time in the local timezone without a default - // location - converted, err = ToTimeE(swd2016str) - if assert.NoError(t, err) { - if format.hasTimezone { - // Converting inputs with a timezone should preserve it - assertTimeEqual(t, swd2016, converted) - assertLocationEqual(t, swd, converted.Location()) - } else { - // Converting inputs without a timezone should be interpreted - // as a local time in UTC. - assertTimeEqual(t, utc2016, converted) - assertLocationEqual(t, time.UTC, converted.Location()) - } - } + t.Run("without default location", func(t *testing.T) { + assert := require.New(t) + converted, err := ToTimeE(est2016str) + assert.NoError(err) + if format.hasTimezone() { + // Converting inputs with a timezone should preserve it + assertTimeEqual(t, est2016, converted) + assertLocationEqual(t, est, converted.Location()) + } else { + // Converting inputs without a timezone should be interpreted + // as a local time in UTC. + assertTimeEqual(t, utc2016, converted) + assertLocationEqual(t, time.UTC, converted.Location()) + } + }) - // Conversion with a nil default location sould have same behavior - converted, err = ToTimeInDefaultLocationE(est2016str, nil) - if assert.NoError(t, err) { - if format.hasTimezone { - // Converting inputs with a timezone should preserve it - assertTimeEqual(t, est2016, converted) - assertLocationEqual(t, est, converted.Location()) - } else { - // Converting inputs without a timezone should be interpreted - // as a local time in the local timezone. - assertTimeEqual(t, swd2016, converted) - assertLocationEqual(t, swd, converted.Location()) - } - } + t.Run("local timezone without a default location", func(t *testing.T) { + assert := require.New(t) + converted, err := ToTimeE(swd2016str) + assert.NoError(err) + if format.hasTimezone() { + // Converting inputs with a timezone should preserve it + assertTimeEqual(t, swd2016, converted) + assertLocationEqual(t, swd, converted.Location()) + } else { + // Converting inputs without a timezone should be interpreted + // as a local time in UTC. + assertTimeEqual(t, utc2016, converted) + assertLocationEqual(t, time.UTC, converted.Location()) + } + }) - // Test conversion with a default location that isn't UTC - converted, err = ToTimeInDefaultLocationE(est2016str, irn) - if assert.NoError(t, err) { - if format.hasTimezone { - // Converting inputs with a timezone should preserve it - assertTimeEqual(t, est2016, converted) - assertLocationEqual(t, est, converted.Location()) - } else { - // Converting inputs without a timezone should be interpreted - // as a local time in the given location. - assertTimeEqual(t, irn2016, converted) - assertLocationEqual(t, irn, 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.hasTimezone() { + // Converting inputs with a timezone should preserve it + assertTimeEqual(t, est2016, converted) + assertLocationEqual(t, est, converted.Location()) + } else { + // Converting inputs without a timezone should be interpreted + // as a local time in the local timezone. + assertTimeEqual(t, loc2016, converted) + assertLocationEqual(t, time.Local, 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.hasTimezone() { + // Converting inputs with a timezone should preserve it + assertTimeEqual(t, est2016, converted) + assertLocationEqual(t, est, converted.Location()) + } else { + // Converting inputs without a timezone should be interpreted + // as a local time in the given location. + 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(swd2016str, irn) + assert.NoError(err) + if format.hasTimezone() { + // Converting inputs with a timezone should preserve it + assertTimeEqual(t, swd2016, converted) + assertLocationEqual(t, swd, converted.Location()) + } else { + // Converting inputs without a timezone should be interpreted + // as a local time in the given location. + assertTimeEqual(t, irn2016, converted) + assertLocationEqual(t, irn, converted.Location()) + } + + }) + + }) - // Test conversion of a time in the local timezone with a default - // location that isn't UTC - converted, err = ToTimeInDefaultLocationE(swd2016str, irn) - if assert.NoError(t, err) { - if format.hasTimezone { - // Converting inputs with a timezone should preserve it - assertTimeEqual(t, swd2016, converted) - assertLocationEqual(t, swd, converted.Location()) - } else { - // Converting inputs without a timezone should be interpreted - // as a local time in the given location. - assertTimeEqual(t, irn2016, converted) - assertLocationEqual(t, irn, converted.Location()) - } - } } } -func assertTimeEqual(t *testing.T, expected, actual time.Time, msgAndArgs ...interface{}) bool { - if !expected.Equal(actual) { - return assert.Fail(t, fmt.Sprintf("Expected time '%s', got '%s'", expected, actual), msgAndArgs...) - } - return true +func assertTimeEqual(t *testing.T, expected, actual time.Time) { + t.Helper() + // Compare the dates using a numeric zone as there are cases where + // time.Parse will assign a dummy location. + // TODO(bep) + //require.Equal(t, expected, actual) + require.Equal(t, expected.Format(time.RFC1123Z), actual.Format(time.RFC1123Z)) } -func assertLocationEqual(t *testing.T, expected, actual *time.Location, msgAndArgs ...interface{}) bool { - if !locationEqual(expected, actual) { - return assert.Fail(t, fmt.Sprintf("Expected location '%s', got '%s'", expected, actual), msgAndArgs...) - } - return true +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 { diff --git a/caste.go b/caste.go index 9d0e4e9..7ad7a84 100644 --- a/caste.go +++ b/caste.go @@ -1246,66 +1246,81 @@ 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 StringToDateInDefaultLocation(s, time.UTC) + return parseDateWith(s, time.UTC, timeFormats) } // StringToDateInDefaultLocation casts an empty interface to a time.Time, // interpreting inputs without a timezone to be in the given location, // or the local timezone if nil. func StringToDateInDefaultLocation(s string, location *time.Location) (time.Time, error) { - if location == nil { - location = time.Local - } return parseDateWith(s, location, timeFormats) } +type timeFormatType int + +const ( + timeFormatNoTimezone timeFormatType = iota + timeFormatNamedTimezone + timeFormatNumericTimezone + timeFormatNumericAndNamedTimezone + timeFormatTimeOnly +) + type timeFormat struct { - format string - hasTimezone bool + format string + typ timeFormatType +} + +func (f timeFormat) hasTimezone() bool { + // We don't include the formats with only named timezones, see + // https://github.com/golang/go/issues/19694#issuecomment-289103522 + return f.typ >= timeFormatNumericTimezone && f.typ <= timeFormatNumericAndNamedTimezone } var ( timeFormats = []timeFormat{ - timeFormat{time.RFC3339, true}, - timeFormat{"2006-01-02T15:04:05", false}, // iso8601 without timezone - timeFormat{time.RFC1123Z, true}, - timeFormat{time.RFC1123, false}, - timeFormat{time.RFC822Z, true}, - timeFormat{time.RFC822, false}, - - timeFormat{time.RFC850, true}, - timeFormat{"2006-01-02 15:04:05.999999999 -0700 MST", true}, // Time.String() - timeFormat{"2006-01-02T15:04:05-0700", true}, // RFC3339 without timezone hh:mm colon - timeFormat{"2006-01-02 15:04:05Z0700", true}, // RFC3339 without T or timezone hh:mm colon - timeFormat{"2006-01-02 15:04:05", false}, - - timeFormat{time.ANSIC, false}, - timeFormat{time.UnixDate, false}, - timeFormat{time.RubyDate, true}, - timeFormat{"2006-01-02 15:04:05Z07:00", true}, - timeFormat{"2006-01-02", false}, - timeFormat{"02 Jan 2006", false}, - timeFormat{"2006-01-02 15:04:05 -07:00", true}, - timeFormat{"2006-01-02 15:04:05 -0700", true}, - timeFormat{time.Kitchen, false}, - timeFormat{time.Stamp, false}, - timeFormat{time.StampMilli, false}, - timeFormat{time.StampMicro, false}, - timeFormat{time.StampNano, false}, + timeFormat{time.RFC3339, timeFormatNumericTimezone}, + timeFormat{"2006-01-02T15:04:05", timeFormatNoTimezone}, // iso8601 without timezone + timeFormat{time.RFC1123Z, timeFormatNumericTimezone}, + timeFormat{time.RFC1123, timeFormatNamedTimezone}, + timeFormat{time.RFC822Z, timeFormatNumericTimezone}, + timeFormat{time.RFC822, timeFormatNamedTimezone}, + timeFormat{time.RFC850, timeFormatNamedTimezone}, + timeFormat{"2006-01-02 15:04:05.999999999 -0700 MST", timeFormatNumericAndNamedTimezone}, // Time.String() + timeFormat{"2006-01-02T15:04:05-0700", timeFormatNumericTimezone}, // RFC3339 without timezone hh:mm colon + timeFormat{"2006-01-02 15:04:05Z0700", timeFormatNumericTimezone}, // RFC3339 without T or timezone hh:mm colon + timeFormat{"2006-01-02 15:04:05", timeFormatNoTimezone}, + timeFormat{time.ANSIC, timeFormatNoTimezone}, + timeFormat{time.UnixDate, timeFormatNamedTimezone}, + timeFormat{time.RubyDate, timeFormatNumericTimezone}, + timeFormat{"2006-01-02 15:04:05Z07:00", timeFormatNumericTimezone}, + timeFormat{"2006-01-02", timeFormatNoTimezone}, + timeFormat{"02 Jan 2006", timeFormatNoTimezone}, + timeFormat{"2006-01-02 15:04:05 -07:00", 1}, + timeFormat{"2006-01-02 15:04:05 -0700", 1}, + timeFormat{time.Kitchen, timeFormatTimeOnly}, + timeFormat{time.Stamp, timeFormatTimeOnly}, + timeFormat{time.StampMilli, timeFormatTimeOnly}, + timeFormat{time.StampMicro, timeFormatTimeOnly}, + timeFormat{time.StampNano, timeFormatTimeOnly}, } ) -func parseDateWith(s string, defaultLocation *time.Location, formats []timeFormat) (d time.Time, e error) { +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. - if !format.hasTimezone && defaultLocation != nil { + if format.typ <= timeFormatNamedTimezone { + if location == nil { + location = time.Local + } year, month, day := d.Date() hour, min, sec := d.Clock() - d = time.Date(year, month, day, hour, min, sec, d.Nanosecond(), defaultLocation) + d = time.Date(year, month, day, hour, min, sec, d.Nanosecond(), location) } return diff --git a/timeformattype_string.go b/timeformattype_string.go new file mode 100644 index 0000000..1524fc8 --- /dev/null +++ b/timeformattype_string.go @@ -0,0 +1,27 @@ +// Code generated by "stringer -type timeFormatType"; DO NOT EDIT. + +package cast + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[timeFormatNoTimezone-0] + _ = x[timeFormatNamedTimezone-1] + _ = x[timeFormatNumericTimezone-2] + _ = x[timeFormatNumericAndNamedTimezone-3] + _ = x[timeFormatTimeOnly-4] +} + +const _timeFormatType_name = "timeFormatNoTimezonetimeFormatNamedTimezonetimeFormatNumericTimezonetimeFormatNumericAndNamedTimezonetimeFormatTimeOnly" + +var _timeFormatType_index = [...]uint8{0, 20, 43, 68, 101, 119} + +func (i timeFormatType) String() string { + if i < 0 || i >= timeFormatType(len(_timeFormatType_index)-1) { + return "timeFormatType(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _timeFormatType_name[_timeFormatType_index[i]:_timeFormatType_index[i+1]] +}