diff --git a/service/dynamodb/dynamodbattribute/decode.go b/service/dynamodb/dynamodbattribute/decode.go index b6afaf1267d..44595857ed6 100644 --- a/service/dynamodb/dynamodbattribute/decode.go +++ b/service/dynamodb/dynamodbattribute/decode.go @@ -181,7 +181,7 @@ func (d *Decoder) decode(av *dynamodb.AttributeValue, v reflect.Value, fieldTag case len(av.M) != 0: return d.decodeMap(av.M, v) case av.N != nil: - return d.decodeNumber(av.N, v) + return d.decodeNumber(av.N, v, fieldTag) case len(av.NS) != 0: return d.decodeNumberSet(av.NS, v) case av.S != nil: @@ -286,7 +286,7 @@ func (d *Decoder) decodeBinarySet(bs [][]byte, v reflect.Value) error { return nil } -func (d *Decoder) decodeNumber(n *string, v reflect.Value) error { +func (d *Decoder) decodeNumber(n *string, v reflect.Value, fieldTag tag) error { switch v.Kind() { case reflect.Interface: i, err := d.decodeNumberToInterface(n) @@ -338,6 +338,14 @@ func (d *Decoder) decodeNumber(n *string, v reflect.Value) error { } v.SetFloat(i) default: + if _, ok := v.Interface().(time.Time); ok && fieldTag.AsUnixTime { + t, err := decodeUnixTime(*n) + if err != nil { + return err + } + v.Set(reflect.ValueOf(t)) + return nil + } return &UnmarshalTypeError{Value: "number", Type: v.Type()} } @@ -367,7 +375,7 @@ func (d *Decoder) decodeNumberSet(ns []*string, v reflect.Value) error { if d.UseNumber { set := make([]Number, len(ns)) for i, n := range ns { - if err := d.decodeNumber(n, reflect.ValueOf(&set[i]).Elem()); err != nil { + if err := d.decodeNumber(n, reflect.ValueOf(&set[i]).Elem(), tag{}); err != nil { return err } } @@ -375,7 +383,7 @@ func (d *Decoder) decodeNumberSet(ns []*string, v reflect.Value) error { } else { set := make([]float64, len(ns)) for i, n := range ns { - if err := d.decodeNumber(n, reflect.ValueOf(&set[i]).Elem()); err != nil { + if err := d.decodeNumber(n, reflect.ValueOf(&set[i]).Elem(), tag{}); err != nil { return err } } @@ -392,7 +400,7 @@ func (d *Decoder) decodeNumberSet(ns []*string, v reflect.Value) error { if u != nil { return u.UnmarshalDynamoDBAttributeValue(&dynamodb.AttributeValue{NS: ns}) } - if err := d.decodeNumber(ns[i], elem); err != nil { + if err := d.decodeNumber(ns[i], elem, tag{}); err != nil { return err } } @@ -489,7 +497,7 @@ func (d *Decoder) decodeNull(v reflect.Value) error { func (d *Decoder) decodeString(s *string, v reflect.Value, fieldTag tag) error { if fieldTag.AsString { - return d.decodeNumber(s, v) + return d.decodeNumber(s, v, fieldTag) } // To maintain backwards compatibility with ConvertFrom family of methods which @@ -552,6 +560,17 @@ func (d *Decoder) decodeStringSet(ss []*string, v reflect.Value) error { return nil } +func decodeUnixTime(n string) (time.Time, error) { + v, err := strconv.ParseInt(n, 10, 64) + if err != nil { + return time.Time{}, &UnmarshalError{ + Err: err, Value: n, Type: reflect.TypeOf(time.Time{}), + } + } + + return time.Unix(v, 0), nil +} + // indirect will walk a value's interface or pointer value types. Returning // the final value or the value a unmarshaler is defined on. // @@ -678,3 +697,36 @@ func (e *InvalidUnmarshalError) Message() string { } return "cannot unmarshal to nil value, " + e.Type.String() } + +// An UnmarshalError wraps an error that occured while unmarshaling a DynamoDB +// AttributeValue element into a Go type. This is different from UnmarshalTypeError +// in that it wraps the underlying error that occured. +type UnmarshalError struct { + Err error + Value string + Type reflect.Type +} + +// Error returns the string representation of the error. +// satisfying the error interface. +func (e *UnmarshalError) Error() string { + return fmt.Sprintf("%s: %s\ncaused by: %v", e.Code(), e.Message(), e.Err) +} + +// OrigErr returns the original error that caused this issue. +func (e UnmarshalError) OrigErr() error { + return e.Err +} + +// Code returns the code of the error, satisfying the awserr.Error +// interface. +func (e *UnmarshalError) Code() string { + return "UnmarshalError" +} + +// Message returns the detailed message of the error, satisfying +// the awserr.Error interface. +func (e *UnmarshalError) Message() string { + return fmt.Sprintf("cannot unmarshal %q into %s.", + e.Value, e.Type.String()) +} diff --git a/service/dynamodb/dynamodbattribute/decode_test.go b/service/dynamodb/dynamodbattribute/decode_test.go index 3876733140c..a4b84d8c7d9 100644 --- a/service/dynamodb/dynamodbattribute/decode_test.go +++ b/service/dynamodb/dynamodbattribute/decode_test.go @@ -494,3 +494,36 @@ func TestDecodeBooleanOverlay(t *testing.T) { assert.NoError(t, err) assert.Equal(t, BooleanOverlay(true), v) } + +func TestDecodeUnixTime(t *testing.T) { + type A struct { + Normal time.Time + Tagged time.Time `dynamodbav:",unixtime"` + Typed UnixTime + } + + expect := A{ + Normal: time.Unix(123, 0).UTC(), + Tagged: time.Unix(456, 0), + Typed: UnixTime(time.Unix(789, 0)), + } + + input := &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Normal": { + S: aws.String("1970-01-01T00:02:03Z"), + }, + "Tagged": { + N: aws.String("456"), + }, + "Typed": { + N: aws.String("789"), + }, + }, + } + actual := A{} + + err := Unmarshal(input, &actual) + assert.NoError(t, err) + assert.Equal(t, expect, actual) +} diff --git a/service/dynamodb/dynamodbattribute/encode.go b/service/dynamodb/dynamodbattribute/encode.go index 632f4018bf4..51adf2f3c4f 100644 --- a/service/dynamodb/dynamodbattribute/encode.go +++ b/service/dynamodb/dynamodbattribute/encode.go @@ -6,9 +6,50 @@ import ( "strconv" "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/dynamodb" ) +// An UnixTime provides aliasing of time.Time into a type that when marshaled +// and unmarshaled with DynamoDB AttributeValues it will be done so as number +// instead of string in seconds since January 1, 1970 UTC. +// +// This type is useful as an alterntitive to the struct tag `unixtime` when you +// want to have your time value marshaled as Unix time in seconds intead of +// the default time.RFC3339. +// +// Important to note that zero value time as unixtime is not 0 seconds +// from January 1, 1970 UTC, but -62135596800. Which is seconds between +// January 1, 0001 UTC, and January 1, 0001 UTC. +type UnixTime time.Time + +// MarshalDynamoDBAttributeValue implements the Marshaler interface so that +// the UnixTime can be marshaled from to a DynamoDB AttributeValue number +// value encoded in the number of seconds since January 1, 1970 UTC. +func (e UnixTime) MarshalDynamoDBAttributeValue(av *dynamodb.AttributeValue) error { + t := time.Time(e) + s := strconv.FormatInt(t.Unix(), 10) + av.N = &s + + return nil +} + +// UnmarshalDynamoDBAttributeValue implements the Unmarshaler interface so that +// the UnixTime can be unmarshaled from a DynamoDB AttributeValue number representing +// the number of seconds since January 1, 1970 UTC. +// +// If an error parsing the AttributeValue number occurs UnmarshalError will be +// returned. +func (e *UnixTime) UnmarshalDynamoDBAttributeValue(av *dynamodb.AttributeValue) error { + t, err := decodeUnixTime(aws.StringValue(av.N)) + if err != nil { + return err + } + + *e = UnixTime(t) + return nil +} + // A Marshaler is an interface to provide custom marshaling of Go value types // to AttributeValues. Use this to provide custom logic determining how a // Go Value type should be marshaled. @@ -75,6 +116,13 @@ type Marshaler interface { // // Field will be marshaled as a string set // Field []string `dynamodbav:",stringset"` // +// // Field will be marshaled as Unix time number in seconds. +// // This tag is only valid with time.Time typed struct fields. +// // Important to note that zero value time as unixtime is not 0 seconds +// // from January 1, 1970 UTC, but -62135596800. Which is seconds between +// // January 1, 0001 UTC, and January 1, 0001 UTC. +// Field time.Time `dynamodbav:",unixtime"` +// // The omitempty tag is only used during Marshaling and is ignored for // Unmarshal. Any zero value or a value when marshaled results in a // AttributeValue NULL will be added to AttributeValue Maps during struct @@ -219,7 +267,7 @@ func (e *Encoder) encode(av *dynamodb.AttributeValue, v reflect.Value, fieldTag case reflect.Invalid: encodeNull(av) case reflect.Struct: - return e.encodeStruct(av, v) + return e.encodeStruct(av, v, fieldTag) case reflect.Map: return e.encodeMap(av, v, fieldTag) case reflect.Slice, reflect.Array: @@ -233,11 +281,13 @@ func (e *Encoder) encode(av *dynamodb.AttributeValue, v reflect.Value, fieldTag return nil } -func (e *Encoder) encodeStruct(av *dynamodb.AttributeValue, v reflect.Value) error { - +func (e *Encoder) encodeStruct(av *dynamodb.AttributeValue, v reflect.Value, fieldTag tag) error { // To maintain backwards compatibility with ConvertTo family of methods which // converted time.Time structs to strings if t, ok := v.Interface().(time.Time); ok { + if fieldTag.AsUnixTime { + return UnixTime(t).MarshalDynamoDBAttributeValue(av) + } s := t.Format(time.RFC3339Nano) av.S = &s return nil diff --git a/service/dynamodb/dynamodbattribute/encode_test.go b/service/dynamodb/dynamodbattribute/encode_test.go index dbc005ef0d2..a78f0cab23c 100644 --- a/service/dynamodb/dynamodbattribute/encode_test.go +++ b/service/dynamodb/dynamodbattribute/encode_test.go @@ -176,3 +176,34 @@ func TestEncodeEmbeddedPointerStruct(t *testing.T) { } assert.Equal(t, expect, actual) } + +func TestEncodeUnixTime(t *testing.T) { + type A struct { + Normal time.Time + Tagged time.Time `dynamodbav:",unixtime"` + Typed UnixTime + } + + a := A{ + Normal: time.Unix(123, 0).UTC(), + Tagged: time.Unix(456, 0), + Typed: UnixTime(time.Unix(789, 0)), + } + + actual, err := Marshal(a) + assert.NoError(t, err) + expect := &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Normal": { + S: aws.String("1970-01-01T00:02:03Z"), + }, + "Tagged": { + N: aws.String("456"), + }, + "Typed": { + N: aws.String("789"), + }, + }, + } + assert.Equal(t, expect, actual) +} diff --git a/service/dynamodb/dynamodbattribute/tag.go b/service/dynamodb/dynamodbattribute/tag.go index c8f9c28b2f4..60bd609b59f 100644 --- a/service/dynamodb/dynamodbattribute/tag.go +++ b/service/dynamodb/dynamodbattribute/tag.go @@ -12,6 +12,7 @@ type tag struct { OmitEmptyElem bool AsString bool AsBinSet, AsNumSet, AsStrSet bool + AsUnixTime bool } func (t *tag) parseAVTag(structTag reflect.StructTag) { @@ -60,6 +61,8 @@ func (t *tag) parseTagStr(tagStr string) { t.AsNumSet = true case "stringset": t.AsStrSet = true + case "unixtime": + t.AsUnixTime = true } } }