diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 8a1927a39ca..70f88e3909a 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -1,4 +1,10 @@ ### SDK Features +* `service/dynamodb/dynamodbattribute`: Add EnableEmptyCollections flag to Encoder and Decoder ([#2834](https://github.com/aws/aws-sdk-go/pull/2834)) + * The `Encoder` and `Decoder` types have been enhanced to allow support for specifying the SDK's behavior when marshaling structures, maps, and slices to DynamoDB. + * When `EnableEmptyCollections` is set to `True` the SDK will preserve the empty of these types in DynamoDB rather then encoding a NULL AttributeValue. + * Fixes [#682](https://github.com/aws/aws-sdk-go/issues/682) + * Fixes [#1890](https://github.com/aws/aws-sdk-go/issues/1890) + * Fixes [#2746](https://github.com/aws/aws-sdk-go/issues/2746) ### SDK Enhancements diff --git a/service/dynamodb/dynamodbattribute/decode.go b/service/dynamodb/dynamodbattribute/decode.go index 1cae8b288d5..b06a587f77c 100644 --- a/service/dynamodb/dynamodbattribute/decode.go +++ b/service/dynamodb/dynamodbattribute/decode.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/dynamodb" ) @@ -155,6 +156,7 @@ var byteSliceType = reflect.TypeOf([]byte(nil)) var byteSliceSlicetype = reflect.TypeOf([][]byte(nil)) var numberType = reflect.TypeOf(Number("")) var timeType = reflect.TypeOf(time.Time{}) +var ptrStringType = reflect.TypeOf(aws.String("")) func (d *Decoder) decode(av *dynamodb.AttributeValue, v reflect.Value, fieldTag tag) error { var u Unmarshaler @@ -172,23 +174,23 @@ func (d *Decoder) decode(av *dynamodb.AttributeValue, v reflect.Value, fieldTag } switch { - case len(av.B) != 0: + case len(av.B) != 0 || (av.B != nil && d.EnableEmptyCollections): return d.decodeBinary(av.B, v) case av.BOOL != nil: return d.decodeBool(av.BOOL, v) - case len(av.BS) != 0: + case len(av.BS) != 0 || (av.BS != nil && d.EnableEmptyCollections): return d.decodeBinarySet(av.BS, v) - case len(av.L) != 0: + case len(av.L) != 0 || (av.L != nil && d.EnableEmptyCollections): return d.decodeList(av.L, v) - case len(av.M) != 0: + case len(av.M) != 0 || (av.M != nil && d.EnableEmptyCollections): return d.decodeMap(av.M, v) case av.N != nil: return d.decodeNumber(av.N, v, fieldTag) - case len(av.NS) != 0: + case len(av.NS) != 0 || (av.NS != nil && d.EnableEmptyCollections): return d.decodeNumberSet(av.NS, v) - case av.S != nil: + case av.S != nil: // DynamoDB does not allow for empty strings, so we do not consider the length or EnableEmptyCollections flag here return d.decodeString(av.S, v, fieldTag) - case len(av.SS) != 0: + case len(av.SS) != 0 || (av.SS != nil && d.EnableEmptyCollections): return d.decodeStringSet(av.SS, v) } diff --git a/service/dynamodb/dynamodbattribute/empty_collections_test.go b/service/dynamodb/dynamodbattribute/empty_collections_test.go new file mode 100644 index 00000000000..0ecfbc49fc1 --- /dev/null +++ b/service/dynamodb/dynamodbattribute/empty_collections_test.go @@ -0,0 +1,663 @@ +package dynamodbattribute + +import ( + "reflect" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" +) + +type testEmptyCollectionsNumericalScalars struct { + String string + + Uint8 uint8 + Uint16 uint16 + Uint32 uint32 + Uint64 uint64 + + Int8 int8 + Int16 int16 + Int32 int32 + Int64 int64 + + Float32 float32 + Float64 float64 +} + +type testEmptyCollectionsOmittedNumericalScalars struct { + String string `dynamodbav:",omitempty"` + + Uint8 uint8 `dynamodbav:",omitempty"` + Uint16 uint16 `dynamodbav:",omitempty"` + Uint32 uint32 `dynamodbav:",omitempty"` + Uint64 uint64 `dynamodbav:",omitempty"` + + Int8 int8 `dynamodbav:",omitempty"` + Int16 int16 `dynamodbav:",omitempty"` + Int32 int32 `dynamodbav:",omitempty"` + Int64 int64 `dynamodbav:",omitempty"` + + Float32 float32 `dynamodbav:",omitempty"` + Float64 float64 `dynamodbav:",omitempty"` +} + +type testEmptyCollectionsPtrScalars struct { + PtrString *string + + PtrUint8 *uint8 + PtrUint16 *uint16 + PtrUint32 *uint32 + PtrUint64 *uint64 + + PtrInt8 *int8 + PtrInt16 *int16 + PtrInt32 *int32 + PtrInt64 *int64 + + PtrFloat32 *float32 + PtrFloat64 *float64 +} + +type testEmptyCollectionsOmittedPtrNumericalScalars struct { + PtrUint8 *uint8 `dynamodbav:",omitempty"` + PtrUint16 *uint16 `dynamodbav:",omitempty"` + PtrUint32 *uint32 `dynamodbav:",omitempty"` + PtrUint64 *uint64 `dynamodbav:",omitempty"` + + PtrInt8 *int8 `dynamodbav:",omitempty"` + PtrInt16 *int16 `dynamodbav:",omitempty"` + PtrInt32 *int32 `dynamodbav:",omitempty"` + PtrInt64 *int64 `dynamodbav:",omitempty"` + + PtrFloat32 *float32 `dynamodbav:",omitempty"` + PtrFloat64 *float64 `dynamodbav:",omitempty"` +} + +type testEmptyCollectionTypes struct { + Map map[string]string + Slice []string + ByteSlice []byte + ByteArray [4]byte + ZeroArray [0]byte + BinarySet [][]byte `dynamodbav:",binaryset"` + NumberSet []int `dynamodbav:",numberset"` + StringSet []string `dynamodbav:",stringset"` +} + +type testEmptyCollectionTypesOmitted struct { + Map map[string]string `dynamodbav:",omitempty"` + Slice []string `dynamodbav:",omitempty"` + ByteSlice []byte `dynamodbav:",omitempty"` + ByteArray [4]byte `dynamodbav:",omitempty"` + ZeroArray [0]byte `dynamodbav:",omitempty"` + BinarySet [][]byte `dynamodbav:",binaryset,omitempty"` + NumberSet []int `dynamodbav:",numberset,omitempty"` + StringSet []string `dynamodbav:",stringset,omitempty"` +} + +type testEmptyCollectionStruct struct { + Int int +} + +type testEmptyCollectionStructOmitted struct { + Slice []string `dynamodbav:",omitempty"` +} + +var sharedEmptyCollectionsTestCases = []struct { + in *dynamodb.AttributeValue + actual, expected interface{} + err error +}{ + // 1. scalars with zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "String": {NULL: aws.Bool(true)}, + "Uint8": {N: aws.String("0")}, + "Uint16": {N: aws.String("0")}, + "Uint32": {N: aws.String("0")}, + "Uint64": {N: aws.String("0")}, + "Int8": {N: aws.String("0")}, + "Int16": {N: aws.String("0")}, + "Int32": {N: aws.String("0")}, + "Int64": {N: aws.String("0")}, + "Float32": {N: aws.String("0")}, + "Float64": {N: aws.String("0")}, + }, + }, + actual: &testEmptyCollectionsNumericalScalars{}, + expected: testEmptyCollectionsNumericalScalars{}, + }, + // 2. scalars with non-zero values + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "String": {S: aws.String("test string")}, + "Uint8": {N: aws.String("1")}, + "Uint16": {N: aws.String("2")}, + "Uint32": {N: aws.String("3")}, + "Uint64": {N: aws.String("4")}, + "Int8": {N: aws.String("-5")}, + "Int16": {N: aws.String("-6")}, + "Int32": {N: aws.String("-7")}, + "Int64": {N: aws.String("-8")}, + "Float32": {N: aws.String("9.9")}, + "Float64": {N: aws.String("10.1")}, + }, + }, + actual: &testEmptyCollectionsNumericalScalars{}, + expected: testEmptyCollectionsNumericalScalars{ + String: "test string", + Uint8: 1, + Uint16: 2, + Uint32: 3, + Uint64: 4, + Int8: -5, + Int16: -6, + Int32: -7, + Int64: -8, + Float32: 9.9, + Float64: 10.1, + }, + }, + // 3. omittable scalars with zero value + { + in: &dynamodb.AttributeValue{M: map[string]*dynamodb.AttributeValue{}}, + actual: &testEmptyCollectionsOmittedNumericalScalars{}, + expected: testEmptyCollectionsOmittedNumericalScalars{}, + }, + // 4. omittable scalars with non-zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "String": {S: aws.String("test string")}, + "Uint8": {N: aws.String("1")}, + "Uint16": {N: aws.String("2")}, + "Uint32": {N: aws.String("3")}, + "Uint64": {N: aws.String("4")}, + "Int8": {N: aws.String("-5")}, + "Int16": {N: aws.String("-6")}, + "Int32": {N: aws.String("-7")}, + "Int64": {N: aws.String("-8")}, + "Float32": {N: aws.String("9.9")}, + "Float64": {N: aws.String("10.1")}, + }, + }, + actual: &testEmptyCollectionsOmittedNumericalScalars{}, + expected: testEmptyCollectionsOmittedNumericalScalars{ + String: "test string", + Uint8: 1, + Uint16: 2, + Uint32: 3, + Uint64: 4, + Int8: -5, + Int16: -6, + Int32: -7, + Int64: -8, + Float32: 9.9, + Float64: 10.1, + }, + }, + // 5. nil pointer scalars + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "PtrString": {NULL: aws.Bool(true)}, + "PtrUint8": {NULL: aws.Bool(true)}, + "PtrUint16": {NULL: aws.Bool(true)}, + "PtrUint32": {NULL: aws.Bool(true)}, + "PtrUint64": {NULL: aws.Bool(true)}, + "PtrInt8": {NULL: aws.Bool(true)}, + "PtrInt16": {NULL: aws.Bool(true)}, + "PtrInt32": {NULL: aws.Bool(true)}, + "PtrInt64": {NULL: aws.Bool(true)}, + "PtrFloat32": {NULL: aws.Bool(true)}, + "PtrFloat64": {NULL: aws.Bool(true)}, + }, + }, + actual: &testEmptyCollectionsPtrScalars{}, + expected: testEmptyCollectionsPtrScalars{}, + }, + // 6. non-nil pointer to scalars with zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "PtrString": {NULL: aws.Bool(true)}, + "PtrUint8": {N: aws.String("0")}, + "PtrUint16": {N: aws.String("0")}, + "PtrUint32": {N: aws.String("0")}, + "PtrUint64": {N: aws.String("0")}, + "PtrInt8": {N: aws.String("0")}, + "PtrInt16": {N: aws.String("0")}, + "PtrInt32": {N: aws.String("0")}, + "PtrInt64": {N: aws.String("0")}, + "PtrFloat32": {N: aws.String("0")}, + "PtrFloat64": {N: aws.String("0")}, + }, + }, + actual: &testEmptyCollectionsPtrScalars{}, + expected: testEmptyCollectionsPtrScalars{ + PtrUint8: aws.Uint8(0), + PtrUint16: aws.Uint16(0), + PtrUint32: aws.Uint32(0), + PtrUint64: aws.Uint64(0), + PtrInt8: aws.Int8(0), + PtrInt16: aws.Int16(0), + PtrInt32: aws.Int32(0), + PtrInt64: aws.Int64(0), + PtrFloat32: aws.Float32(0), + PtrFloat64: aws.Float64(0), + }, + }, + // 7. pointer scalars non-nil non-zero + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "PtrString": {S: aws.String("test string")}, + "PtrUint8": {N: aws.String("1")}, + "PtrUint16": {N: aws.String("2")}, + "PtrUint32": {N: aws.String("3")}, + "PtrUint64": {N: aws.String("4")}, + "PtrInt8": {N: aws.String("-5")}, + "PtrInt16": {N: aws.String("-6")}, + "PtrInt32": {N: aws.String("-7")}, + "PtrInt64": {N: aws.String("-8")}, + "PtrFloat32": {N: aws.String("9.9")}, + "PtrFloat64": {N: aws.String("10.1")}, + }, + }, + actual: &testEmptyCollectionsPtrScalars{}, + expected: testEmptyCollectionsPtrScalars{ + PtrString: aws.String("test string"), + PtrUint8: aws.Uint8(1), + PtrUint16: aws.Uint16(2), + PtrUint32: aws.Uint32(3), + PtrUint64: aws.Uint64(4), + PtrInt8: aws.Int8(-5), + PtrInt16: aws.Int16(-6), + PtrInt32: aws.Int32(-7), + PtrInt64: aws.Int64(-8), + PtrFloat32: aws.Float32(9.9), + PtrFloat64: aws.Float64(10.1), + }, + }, + // 8. omittable nil pointer scalars + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{}, + }, + actual: &testEmptyCollectionsOmittedPtrNumericalScalars{}, + expected: testEmptyCollectionsOmittedPtrNumericalScalars{}, + }, + // 9. omittable non-nil pointer to scalars with zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "PtrUint8": {N: aws.String("0")}, + "PtrUint16": {N: aws.String("0")}, + "PtrUint32": {N: aws.String("0")}, + "PtrUint64": {N: aws.String("0")}, + "PtrInt8": {N: aws.String("0")}, + "PtrInt16": {N: aws.String("0")}, + "PtrInt32": {N: aws.String("0")}, + "PtrInt64": {N: aws.String("0")}, + "PtrFloat32": {N: aws.String("0")}, + "PtrFloat64": {N: aws.String("0")}, + }, + }, + actual: &testEmptyCollectionsOmittedPtrNumericalScalars{}, + expected: testEmptyCollectionsOmittedPtrNumericalScalars{ + PtrUint8: aws.Uint8(0), + PtrUint16: aws.Uint16(0), + PtrUint32: aws.Uint32(0), + PtrUint64: aws.Uint64(0), + PtrInt8: aws.Int8(0), + PtrInt16: aws.Int16(0), + PtrInt32: aws.Int32(0), + PtrInt64: aws.Int64(0), + PtrFloat32: aws.Float32(0), + PtrFloat64: aws.Float64(0), + }, + }, + // 10. omittable non-nil pointer to non-zero scalar + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "PtrUint8": {N: aws.String("1")}, + "PtrUint16": {N: aws.String("2")}, + "PtrUint32": {N: aws.String("3")}, + "PtrUint64": {N: aws.String("4")}, + "PtrInt8": {N: aws.String("-5")}, + "PtrInt16": {N: aws.String("-6")}, + "PtrInt32": {N: aws.String("-7")}, + "PtrInt64": {N: aws.String("-8")}, + "PtrFloat32": {N: aws.String("9.9")}, + "PtrFloat64": {N: aws.String("10.1")}, + }, + }, + actual: &testEmptyCollectionsOmittedPtrNumericalScalars{}, + expected: testEmptyCollectionsOmittedPtrNumericalScalars{ + PtrUint8: aws.Uint8(1), + PtrUint16: aws.Uint16(2), + PtrUint32: aws.Uint32(3), + PtrUint64: aws.Uint64(4), + PtrInt8: aws.Int8(-5), + PtrInt16: aws.Int16(-6), + PtrInt32: aws.Int32(-7), + PtrInt64: aws.Int64(-8), + PtrFloat32: aws.Float32(9.9), + PtrFloat64: aws.Float64(10.1), + }, + }, + // 11. maps, slices nil values + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Map": {NULL: aws.Bool(true)}, + "Slice": {NULL: aws.Bool(true)}, + "ByteSlice": {NULL: aws.Bool(true)}, + "ByteArray": {B: make([]byte, 4)}, + "ZeroArray": {B: make([]byte, 0)}, + "BinarySet": {NULL: aws.Bool(true)}, + "NumberSet": {NULL: aws.Bool(true)}, + "StringSet": {NULL: aws.Bool(true)}, + }, + }, + actual: &testEmptyCollectionTypes{}, + expected: testEmptyCollectionTypes{}, + }, + // 12. maps, slices zero values + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Map": {M: map[string]*dynamodb.AttributeValue{}}, + "Slice": {L: []*dynamodb.AttributeValue{}}, + "ByteSlice": {B: []byte{}}, + "ByteArray": {B: make([]byte, 4)}, + "ZeroArray": {B: make([]byte, 0)}, + "BinarySet": {BS: [][]byte{}}, + "NumberSet": {NS: []*string{}}, + "StringSet": {SS: []*string{}}, + }, + }, + actual: &testEmptyCollectionTypes{}, + expected: testEmptyCollectionTypes{ + Map: map[string]string{}, + Slice: []string{}, + ByteSlice: []byte{}, + ByteArray: [4]byte{}, + ZeroArray: [0]byte{}, + BinarySet: [][]byte{}, + NumberSet: []int{}, + StringSet: []string{}, + }, + }, + // 13. maps, slices non-zero values + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Map": { + M: map[string]*dynamodb.AttributeValue{ + "key": {S: aws.String("value")}, + }, + }, + "Slice": {L: []*dynamodb.AttributeValue{{S: aws.String("test")}, {S: aws.String("slice")}}}, + "ByteSlice": {B: []byte{0, 1}}, + "ByteArray": {B: []byte{0, 1, 2, 3}}, + "ZeroArray": {B: make([]byte, 0)}, + "BinarySet": {BS: [][]byte{{0, 1}, {2, 3}}}, + "NumberSet": {NS: []*string{aws.String("0"), aws.String("1")}}, + "StringSet": {SS: []*string{aws.String("test"), aws.String("slice")}}, + }, + }, + actual: &testEmptyCollectionTypes{}, + expected: testEmptyCollectionTypes{ + Map: map[string]string{"key": "value"}, + Slice: []string{"test", "slice"}, + ByteSlice: []byte{0, 1}, + ByteArray: [4]byte{0, 1, 2, 3}, + ZeroArray: [0]byte{}, + BinarySet: [][]byte{{0, 1}, {2, 3}}, + NumberSet: []int{0, 1}, + StringSet: []string{"test", "slice"}, + }, + }, + // 14. omittable maps, slices nil values + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "ByteArray": {B: make([]byte, 4)}, + }, + }, + actual: &testEmptyCollectionTypesOmitted{}, + expected: testEmptyCollectionTypesOmitted{}, + }, + // 15. omittable maps, slices zero values + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Map": {M: map[string]*dynamodb.AttributeValue{}}, + "Slice": {L: []*dynamodb.AttributeValue{}}, + "ByteSlice": {B: []byte{}}, + "ByteArray": {B: make([]byte, 4)}, + "BinarySet": {BS: [][]byte{}}, + "NumberSet": {NS: []*string{}}, + "StringSet": {SS: []*string{}}, + }, + }, + actual: &testEmptyCollectionTypesOmitted{}, + expected: testEmptyCollectionTypesOmitted{ + Map: map[string]string{}, + Slice: []string{}, + ByteSlice: []byte{}, + ByteArray: [4]byte{}, + BinarySet: [][]byte{}, + NumberSet: []int{}, + StringSet: []string{}, + }, + }, + // 16. omittable maps, slices non-zero values + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Map": { + M: map[string]*dynamodb.AttributeValue{ + "key": {S: aws.String("value")}, + }, + }, + "Slice": {L: []*dynamodb.AttributeValue{{S: aws.String("test")}, {S: aws.String("slice")}}}, + "ByteSlice": {B: []byte{0, 1}}, + "ByteArray": {B: []byte{0, 1, 2, 3}}, + "BinarySet": {BS: [][]byte{{0, 1}, {2, 3}}}, + "NumberSet": {NS: []*string{aws.String("0"), aws.String("1")}}, + "StringSet": {SS: []*string{aws.String("test"), aws.String("slice")}}, + }, + }, + actual: &testEmptyCollectionTypesOmitted{}, + expected: testEmptyCollectionTypesOmitted{ + Map: map[string]string{"key": "value"}, + Slice: []string{"test", "slice"}, + ByteSlice: []byte{0, 1}, + ByteArray: [4]byte{0, 1, 2, 3}, + ZeroArray: [0]byte{}, + BinarySet: [][]byte{{0, 1}, {2, 3}}, + NumberSet: []int{0, 1}, + StringSet: []string{"test", "slice"}, + }, + }, + // 17. structs with members zero + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Struct": { + M: map[string]*dynamodb.AttributeValue{ + "Int": {N: aws.String("0")}, + }, + }, + "PtrStruct": {NULL: aws.Bool(true)}, + }, + }, + actual: &struct { + Struct testEmptyCollectionStruct + PtrStruct *testEmptyCollectionStruct + }{}, + expected: struct { + Struct testEmptyCollectionStruct + PtrStruct *testEmptyCollectionStruct + }{}, + }, + // 18. structs with members non-zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Struct": { + M: map[string]*dynamodb.AttributeValue{ + "Int": {N: aws.String("1")}, + }, + }, + "PtrStruct": { + M: map[string]*dynamodb.AttributeValue{ + "Int": {N: aws.String("1")}, + }, + }, + }, + }, + actual: &struct { + Struct testEmptyCollectionStruct + PtrStruct *testEmptyCollectionStruct + }{}, + expected: struct { + Struct testEmptyCollectionStruct + PtrStruct *testEmptyCollectionStruct + }{ + Struct: testEmptyCollectionStruct{Int: 1}, + PtrStruct: &testEmptyCollectionStruct{Int: 1}, + }, + }, + // 19. struct with omittable members zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Struct": {M: map[string]*dynamodb.AttributeValue{}}, + "PtrStruct": {NULL: aws.Bool(true)}, + }, + }, + actual: &struct { + Struct testEmptyCollectionStructOmitted + PtrStruct *testEmptyCollectionStructOmitted + }{}, + expected: struct { + Struct testEmptyCollectionStructOmitted + PtrStruct *testEmptyCollectionStructOmitted + }{}, + }, + // 20. omittable struct with omittable members zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Struct": {M: map[string]*dynamodb.AttributeValue{}}, + }, + }, + actual: &struct { + Struct testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + PtrStruct *testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + }{}, + expected: struct { + Struct testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + PtrStruct *testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + }{}, + }, + // 21. omittable struct with omittable members non-zero value + { + in: &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "Struct": { + M: map[string]*dynamodb.AttributeValue{ + "Slice": {L: []*dynamodb.AttributeValue{{S: aws.String("test")}}}, + }, + }, + "InitPtrStruct": { + M: map[string]*dynamodb.AttributeValue{ + "Slice": {L: []*dynamodb.AttributeValue{{S: aws.String("test")}}}, + }, + }, + }, + }, + actual: &struct { + Struct testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + InitPtrStruct *testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + }{}, + expected: struct { + Struct testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + InitPtrStruct *testEmptyCollectionStructOmitted `dynamodbav:",omitempty"` + }{ + Struct: testEmptyCollectionStructOmitted{Slice: []string{"test"}}, + InitPtrStruct: &testEmptyCollectionStructOmitted{Slice: []string{"test"}}, + }, + }, +} + +func TestMarshalEmptyCollections(t *testing.T) { + for i, c := range sharedEmptyCollectionsTestCases { + encoder := NewEncoder(func(e *Encoder) { + e.EnableEmptyCollections = true + }) + av, err := encoder.Encode(c.expected) + assertConvertTest(t, i, av, c.in, err, c.err) + } +} + +func TestEmptyCollectionsSpecialCases(t *testing.T) { + // ptr string non nil with empty value + + type SpecialCases struct { + PtrString *string + OmittedPtrString *string `dynamodbav:",omitempty"` + } + + expectedEncode := &dynamodb.AttributeValue{ + M: map[string]*dynamodb.AttributeValue{ + "PtrString": {NULL: aws.Bool(true)}, + }, + } + expectedDecode := SpecialCases{} + + encoder := NewEncoder(func(encoder *Encoder) { + encoder.EnableEmptyCollections = true + }) + + actualEncode, err := encoder.Encode(&SpecialCases{ + PtrString: aws.String(""), + OmittedPtrString: aws.String(""), + }) + if err != nil { + t.Fatalf("expected no err got %v", err) + } + if e, a := expectedEncode, actualEncode; !reflect.DeepEqual(e, a) { + t.Errorf("expected %v, got %v", e, a) + } + + decoder := NewDecoder(func(decoder *Decoder) { + decoder.EnableEmptyCollections = true + }) + + var actualDecode SpecialCases + err = decoder.Decode(&dynamodb.AttributeValue{}, &actualDecode) + if err != nil { + t.Fatalf("expected no err got %v", err) + } + if e, a := expectedDecode, actualDecode; !reflect.DeepEqual(e, a) { + t.Errorf("expected %v, got %v", e, a) + } +} + +func TestUnmarshalEmptyCollections(t *testing.T) { + for i, c := range sharedEmptyCollectionsTestCases { + decoder := NewDecoder(func(d *Decoder) { + d.EnableEmptyCollections = true + }) + err := decoder.Decode(c.in, c.actual) + assertConvertTest(t, i, c.actual, c.expected, err, c.err) + } +} diff --git a/service/dynamodb/dynamodbattribute/encode.go b/service/dynamodb/dynamodbattribute/encode.go index 15c80785ccb..c03e01d592f 100644 --- a/service/dynamodb/dynamodbattribute/encode.go +++ b/service/dynamodb/dynamodbattribute/encode.go @@ -194,6 +194,13 @@ type MarshalOptions struct { // Note that values provided with a custom TagKey must also be supported // by the (un)marshalers in this package. TagKey string + + // EnableEmptyCollections modifies how structures, maps, and slices are (un)marshalled. + // When set to true empty collection values will be preserved as their respective + // empty DynamoDB AttributeValue type when set to true. + // + // Disabled by default. + EnableEmptyCollections bool } // An Encoder provides marshaling Go value types to AttributeValues. @@ -255,7 +262,7 @@ func fieldByIndex(v reflect.Value, index []int, func (e *Encoder) encode(av *dynamodb.AttributeValue, v reflect.Value, fieldTag tag) error { // We should check for omitted values first before dereferencing. - if fieldTag.OmitEmpty && emptyValue(v) { + if fieldTag.OmitEmpty && emptyValue(v, e.EnableEmptyCollections) { encodeNull(av) return nil } @@ -330,7 +337,7 @@ func (e *Encoder) encodeStruct(av *dynamodb.AttributeValue, v reflect.Value, fie av.M[f.Name] = elem } - if len(av.M) == 0 { + if len(av.M) == 0 && !e.EnableEmptyCollections { encodeNull(av) } @@ -357,7 +364,8 @@ func (e *Encoder) encodeMap(av *dynamodb.AttributeValue, v reflect.Value, fieldT av.M[keyName] = elem } - if len(av.M) == 0 { + + if v.IsNil() || (len(av.M) == 0 && !e.EnableEmptyCollections) { encodeNull(av) } @@ -365,13 +373,18 @@ func (e *Encoder) encodeMap(av *dynamodb.AttributeValue, v reflect.Value, fieldT } func (e *Encoder) encodeSlice(av *dynamodb.AttributeValue, v reflect.Value, fieldTag tag) error { + if v.Kind() == reflect.Array && v.Len() == 0 && e.EnableEmptyCollections && fieldTag.OmitEmpty { + encodeNull(av) + return nil + } + switch v.Type().Elem().Kind() { case reflect.Uint8: slice := reflect.MakeSlice(byteSliceType, v.Len(), v.Len()) reflect.Copy(slice, v) b := slice.Bytes() - if len(b) == 0 { + if (v.Kind() == reflect.Slice && v.IsNil()) || (len(b) == 0 && !e.EnableEmptyCollections) { encodeNull(av) return nil } @@ -416,7 +429,7 @@ func (e *Encoder) encodeSlice(av *dynamodb.AttributeValue, v reflect.Value, fiel if n, err := e.encodeList(v, fieldTag, elemFn); err != nil { return err - } else if n == 0 { + } else if (v.Kind() == reflect.Slice && v.IsNil()) || (n == 0 && !e.EnableEmptyCollections) { encodeNull(av) } } @@ -489,8 +502,10 @@ func (e *Encoder) encodeNumber(av *dynamodb.AttributeValue, v reflect.Value) err out = encodeInt(v.Int()) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: out = encodeUint(v.Uint()) - case reflect.Float32, reflect.Float64: - out = encodeFloat(v.Float()) + case reflect.Float32: + out = encodeFloat(v.Float(), 32) + case reflect.Float64: + out = encodeFloat(v.Float(), 64) default: return &unsupportedMarshalTypeError{Type: v.Type()} } @@ -526,8 +541,8 @@ func encodeInt(i int64) string { func encodeUint(u uint64) string { return strconv.FormatUint(u, 10) } -func encodeFloat(f float64) string { - return strconv.FormatFloat(f, 'f', -1, 64) +func encodeFloat(f float64, bitSize int) string { + return strconv.FormatFloat(f, 'f', -1, bitSize) } func encodeNull(av *dynamodb.AttributeValue) { t := true @@ -545,9 +560,13 @@ func valueElem(v reflect.Value) reflect.Value { return v } -func emptyValue(v reflect.Value) bool { +func emptyValue(v reflect.Value, emptyCollections bool) bool { switch v.Kind() { - case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + case reflect.Array: + return v.Len() == 0 && !emptyCollections + case reflect.Map, reflect.Slice: + return v.IsNil() || (v.Len() == 0 && !emptyCollections) + case reflect.String: return v.Len() == 0 case reflect.Bool: return !v.Bool()