diff --git a/cast.go b/cast.go index 9fba638..0cfe941 100644 --- a/cast.go +++ b/cast.go @@ -20,6 +20,11 @@ func ToTime(i interface{}) time.Time { return v } +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 cc4ebce..1912bcd 100644 --- a/cast_test.go +++ b/cast_test.go @@ -1293,3 +1293,172 @@ func TestToDurationE(t *testing.T) { assert.Equal(t, test.expect, v, errmsg) } } + +func TestToTime(t *testing.T) { + est, err := time.LoadLocation("EST") + if !assert.NoError(t, err) { + return + } + + irn, err := time.LoadLocation("Iran") + if !assert.NoError(t, err) { + return + } + + 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 + }() + } + + // 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) + + 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) { + continue + } + + swd2016str := swd2016.Format(format.format) + if !assert.NotEmpty(t, swd2016str) { + continue + } + + // 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()) + } + } + + // 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()) + } + } + + // 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()) + } + } + + // 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()) + } + } + + // 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 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 locationEqual(a, b *time.Location) bool { + // A note about comparring 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 9ac1015..9d0e4e9 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, time.UTC) +} + +// ToTimeInDefaultLocationE casts an empty interface to time.Time, +// interpreting inputs without a timezone to be in the given location, +// or the local timezone if nil. +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: @@ -1239,37 +1246,68 @@ 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 StringToDateInDefaultLocation(s, time.UTC) } -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 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 timeFormat struct { + format string + hasTimezone bool +} + +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}, + } +) + +func parseDateWith(s string, defaultLocation *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 { + year, month, day := d.Date() + hour, min, sec := d.Clock() + d = time.Date(year, month, day, hour, min, sec, d.Nanosecond(), defaultLocation) + } + return } }