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.
This commit is contained in:
Heewa Barfchin 2016-03-16 14:25:51 -04:00
parent 27b586b42e
commit 7bb19bef64
3 changed files with 232 additions and 21 deletions

View File

@ -17,6 +17,11 @@ func ToTime(i interface{}) time.Time {
return v return v
} }
func ToTimeInDefaultLocation(i interface{}, location *time.Location) time.Time {
v, _ := ToTimeInDefaultLocationE(i, location)
return v
}
func ToDuration(i interface{}) time.Duration { func ToDuration(i interface{}) time.Duration {
v, _ := ToDurationE(i) v, _ := ToDurationE(i)
return v return v

View File

@ -6,6 +6,7 @@
package cast package cast
import ( import (
"fmt"
"html/template" "html/template"
"testing" "testing"
"time" "time"
@ -177,3 +178,172 @@ func TestToDuration(t *testing.T) {
assert.Equal(t, ToDuration(ai), a) assert.Equal(t, ToDuration(ai), a)
assert.Equal(t, ToDuration(bf), b) assert.Equal(t, ToDuration(bf), b)
} }
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)
}

View File

@ -18,6 +18,13 @@ import (
// ToTimeE casts an empty interface to time.Time. // ToTimeE casts an empty interface to time.Time.
func ToTimeE(i interface{}) (tim time.Time, err error) { 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) i = indirect(i)
jww.DEBUG.Println("ToTimeE called on type:", reflect.TypeOf(i)) jww.DEBUG.Println("ToTimeE called on type:", reflect.TypeOf(i))
@ -25,7 +32,7 @@ func ToTimeE(i interface{}) (tim time.Time, err error) {
case time.Time: case time.Time:
return s, nil return s, nil
case string: case string:
d, e := StringToDate(s) d, e := StringToDateInDefaultLocation(s, location)
if e == nil { if e == nil {
return d, nil return d, nil
} }
@ -471,28 +478,57 @@ func ToIntSliceE(i interface{}) ([]int, error) {
// StringToDate casts an empty interface to a time.Time. // StringToDate casts an empty interface to a time.Time.
func StringToDate(s string) (time.Time, error) { func StringToDate(s string) (time.Time, error) {
return parseDateWith(s, []string{ return StringToDateInDefaultLocation(s, time.UTC)
time.RFC3339,
"2006-01-02T15:04:05", // iso8601 without timezone
time.RFC1123Z,
time.RFC1123,
time.RFC822Z,
time.RFC822,
time.ANSIC,
time.UnixDate,
time.RubyDate,
"2006-01-02 15:04:05Z07:00",
"02 Jan 06 15:04 MST",
"2006-01-02",
"02 Jan 2006",
"2006-01-02 15:04:05 -07:00",
"2006-01-02 15:04:05 -0700",
})
} }
func parseDateWith(s string, dates []string) (d time.Time, e error) { // StringToDateInDefaultLocation casts an empty interface to a time.Time,
for _, dateType := range dates { // interpreting inputs without a timezone to be in the given location,
if d, e = time.Parse(dateType, s); e == nil { // 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.ANSIC, false},
timeFormat{time.UnixDate, false},
timeFormat{time.RubyDate, true},
timeFormat{"2006-01-02 15:04:05Z07:00", true},
timeFormat{"02 Jan 06 15:04 MST", false},
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},
}
)
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 return
} }
} }