Skip to content

Commit

Permalink
service/dynamodb/dynamodbattribute: Add helper to marshal time as Uni…
Browse files Browse the repository at this point in the history
…x time. (#1119)

Adds support for an additional struct tag unixtime and a new type
UnixTime that make it easier to marshal time as seconds since January
1, 1970 UTC. This is useful for marshaling time values into unix time
for DynamodDB features such as TTL and CreatedAt.
  • Loading branch information
jasdel authored Mar 13, 2017
1 parent 963d664 commit 3b55178
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 9 deletions.
64 changes: 58 additions & 6 deletions service/dynamodb/dynamodbattribute/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()}
}

Expand Down Expand Up @@ -367,15 +375,15 @@ 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
}
}
v.Set(reflect.ValueOf(set))
} 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
}
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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())
}
33 changes: 33 additions & 0 deletions service/dynamodb/dynamodbattribute/decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
56 changes: 53 additions & 3 deletions service/dynamodb/dynamodbattribute/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions service/dynamodb/dynamodbattribute/encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions service/dynamodb/dynamodbattribute/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type tag struct {
OmitEmptyElem bool
AsString bool
AsBinSet, AsNumSet, AsStrSet bool
AsUnixTime bool
}

func (t *tag) parseAVTag(structTag reflect.StructTag) {
Expand Down Expand Up @@ -60,6 +61,8 @@ func (t *tag) parseTagStr(tagStr string) {
t.AsNumSet = true
case "stringset":
t.AsStrSet = true
case "unixtime":
t.AsUnixTime = true
}
}
}

0 comments on commit 3b55178

Please sign in to comment.