diff --git a/.changelog/93dbc158-0acc-4482-b5cb-727186ec548b.json b/.changelog/93dbc158-0acc-4482-b5cb-727186ec548b.json new file mode 100644 index 00000000000..d8729749bec --- /dev/null +++ b/.changelog/93dbc158-0acc-4482-b5cb-727186ec548b.json @@ -0,0 +1,10 @@ +{ + "id": "93dbc158-0acc-4482-b5cb-727186ec548b", + "type": "bugfix", + "description": "attributevalue marshal to AttributeValueMemberNULL with double pointer when outer pointer is not nil", + "collapse": false, + "modules": [ + "feature/dynamodb/attributevalue", + "feature/dynamodbstreams/attributevalue" + ] +} \ No newline at end of file diff --git a/feature/dynamodb/attributevalue/decode_test.go b/feature/dynamodb/attributevalue/decode_test.go index c99887eacbb..cd8c038600b 100644 --- a/feature/dynamodb/attributevalue/decode_test.go +++ b/feature/dynamodb/attributevalue/decode_test.go @@ -1344,3 +1344,95 @@ func TestUnmarshalIndividualSetValues(t *testing.T) { t.Errorf("expect value match\n%s", diff) } } + +func TestDecodeDoublePointer(t *testing.T) { + type output struct { + Foo **int `dynamodbav:"Foo"` + } + + number := 5 + foo := &number + + cases := []struct { + input types.AttributeValue + expected output + expectError bool + }{ + { + input: &types.AttributeValueMemberM{ + Value: nil, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{}, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": nil, + }, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{ + Value: true, + }, + }, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberN{ + Value: "5", + }, + }, + }, + expected: output{ + Foo: &foo, + }, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberS{ + Value: "5", + }, + }, + }, + expectError: true, + }, + } + + for i, c := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + var actual output + err := Unmarshal(c.input, &actual) + if c.expectError && err == nil { + t.Error("expected error") + t.Fail() + } + if !c.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + t.Fail() + } + + if c.expectError && err != nil { + t.Logf("found expected error: %v", err) + return + } + + if diff := cmpDiff(c.expected, actual); diff != "" { + t.Errorf("unexpected diff: %v", diff) + } + }) + } +} diff --git a/feature/dynamodb/attributevalue/encode.go b/feature/dynamodb/attributevalue/encode.go index 441a6fdfdf9..e33b057797c 100644 --- a/feature/dynamodb/attributevalue/encode.go +++ b/feature/dynamodb/attributevalue/encode.go @@ -490,6 +490,18 @@ func (e *Encoder) encode(v reflect.Value, fieldTag tag) (types.AttributeValue, e case reflect.Chan, reflect.Func, reflect.UnsafePointer: // skip unsupported types return nil, nil + // handle double pointers + case reflect.Pointer: + el := v.Elem() + if el.IsValid() { + return e.encode(el, fieldTag) + } + + if fieldTag.OmitEmpty && fieldTag.OmitEmptyElem { + return nil, nil + } + + return encodeNull(), nil default: return e.encodeScalar(v, fieldTag) @@ -549,9 +561,19 @@ func (e *Encoder) encodeMap(v reflect.Value, fieldTag tag) (types.AttributeValue } elemVal := v.MapIndex(key) + // Both OmitEmpty and OmitEmptyElem are set to fieldTag.OmitEmptyElem here. + // This ensures that omitempty logic is applied both to the map element itself (OmitEmpty) + // and recursively to any nested elements (OmitEmptyElem), so that omitempty is respected + // at all levels of nested maps or slices. This is necessary for correct DynamoDB marshaling + // when omitemptyelem is specified on the parent map or slice field. + // + // Note: For pointer types (including double pointers), omitempty only omits the field if the outer pointer is nil. + // If the outer pointer is non-nil but the inner pointer is nil (e.g., **int where *int is nil), + // omitempty does not omit the field; instead, the encoder emits a DynamoDB NULL attribute for that field. elem, err := e.encode(elemVal, tag{ - OmitEmpty: fieldTag.OmitEmptyElem, - NullEmpty: fieldTag.NullEmptyElem, + OmitEmpty: fieldTag.OmitEmptyElem, + OmitEmptyElem: fieldTag.OmitEmptyElem, + NullEmpty: fieldTag.NullEmptyElem, }) if err != nil { return nil, err @@ -807,9 +829,7 @@ func encoderFieldByIndex(v reflect.Value, index []int) (reflect.Value, bool) { func valueElem(v reflect.Value) reflect.Value { switch v.Kind() { case reflect.Interface, reflect.Ptr: - for v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr { - v = v.Elem() - } + v = v.Elem() } return v diff --git a/feature/dynamodb/attributevalue/encode_test.go b/feature/dynamodb/attributevalue/encode_test.go index c7e76835bda..bad855a0643 100644 --- a/feature/dynamodb/attributevalue/encode_test.go +++ b/feature/dynamodb/attributevalue/encode_test.go @@ -866,3 +866,145 @@ func TestEncodeEmptyTime(t *testing.T) { t.Errorf("expect %v, got %v", e, a) } } + +func TestEncodeDoublePointerOmitEmpty(t *testing.T) { + type input struct { + Foo **int `dynamodbav:"Foo,omitempty"` + } + var nilDouble **int + var nilSingle *int + number := 42 + nonNil := &number + + cases := []struct { + input input + expected types.AttributeValue + }{ + { + input: input{ + Foo: nil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{}, + }, + }, + { + input: input{ + Foo: nilDouble, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{}, + }, + }, + { + input: input{ + Foo: &nilSingle, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{ + Value: true, + }, + }, + }, + }, + { + input: input{ + Foo: &nonNil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberN{ + Value: "42", + }, + }, + }, + }, + } + + for i, c := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := Marshal(c.input) + if err != nil { + t.Errorf("unexpected error: %v", err) + t.Fail() + } + + if diff := cmpDiff(c.expected, actual); diff != "" { + t.Errorf("unexpected diff: %v", diff) + } + }) + } +} + +func TestEncodeDoublePointer(t *testing.T) { + type input struct { + Foo **int `dynamodbav:"Foo"` + } + var nilDouble **int + var nilSingle *int + number := 42 + nonNil := &number + + cases := []struct { + input input + expected types.AttributeValue + }{ + { + input: input{ + Foo: nil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{Value: true}, + }, + }, + }, + { + input: input{ + Foo: nilDouble, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{Value: true}, + }, + }, + }, + { + input: input{ + Foo: &nilSingle, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{Value: true}, + }, + }, + }, + { + input: input{ + Foo: &nonNil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberN{ + Value: "42", + }, + }, + }, + }, + } + + for i, c := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := Marshal(c.input) + if err != nil { + t.Errorf("unexpected error: %v", err) + t.Fail() + } + + if diff := cmpDiff(c.expected, actual); diff != "" { + t.Errorf("unexpected diff: %v", diff) + } + }) + } +} diff --git a/feature/dynamodbstreams/attributevalue/decode_test.go b/feature/dynamodbstreams/attributevalue/decode_test.go index 0de682390cd..5dc3480e45d 100644 --- a/feature/dynamodbstreams/attributevalue/decode_test.go +++ b/feature/dynamodbstreams/attributevalue/decode_test.go @@ -1344,3 +1344,95 @@ func TestUnmarshalIndividualSetValues(t *testing.T) { t.Errorf("expect value match\n%s", diff) } } + +func TestDecodeDoublePointer(t *testing.T) { + type output struct { + Foo **int `dynamodbav:"Foo"` + } + + number := 5 + foo := &number + + cases := []struct { + input types.AttributeValue + expected output + expectError bool + }{ + { + input: &types.AttributeValueMemberM{ + Value: nil, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{}, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": nil, + }, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{ + Value: true, + }, + }, + }, + expected: output{}, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberN{ + Value: "5", + }, + }, + }, + expected: output{ + Foo: &foo, + }, + }, + { + input: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberS{ + Value: "5", + }, + }, + }, + expectError: true, + }, + } + + for i, c := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + var actual output + err := Unmarshal(c.input, &actual) + if c.expectError && err == nil { + t.Error("expected error") + t.Fail() + } + if !c.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + t.Fail() + } + + if c.expectError && err != nil { + t.Logf("found expected error: %v", err) + return + } + + if diff := cmpDiff(c.expected, actual); diff != "" { + t.Errorf("unexpected diff: %v", diff) + } + }) + } +} diff --git a/feature/dynamodbstreams/attributevalue/encode.go b/feature/dynamodbstreams/attributevalue/encode.go index 871a051c430..55b23e849a9 100644 --- a/feature/dynamodbstreams/attributevalue/encode.go +++ b/feature/dynamodbstreams/attributevalue/encode.go @@ -490,6 +490,18 @@ func (e *Encoder) encode(v reflect.Value, fieldTag tag) (types.AttributeValue, e case reflect.Chan, reflect.Func, reflect.UnsafePointer: // skip unsupported types return nil, nil + // handle double pointers + case reflect.Pointer: + el := v.Elem() + if el.IsValid() { + return e.encode(el, fieldTag) + } + + if fieldTag.OmitEmpty && fieldTag.OmitEmptyElem { + return nil, nil + } + + return encodeNull(), nil default: return e.encodeScalar(v, fieldTag) @@ -549,9 +561,19 @@ func (e *Encoder) encodeMap(v reflect.Value, fieldTag tag) (types.AttributeValue } elemVal := v.MapIndex(key) + // Both OmitEmpty and OmitEmptyElem are set to fieldTag.OmitEmptyElem here. + // This ensures that omitempty logic is applied both to the map element itself (OmitEmpty) + // and recursively to any nested elements (OmitEmptyElem), so that omitempty is respected + // at all levels of nested maps or slices. This is necessary for correct DynamoDB marshaling + // when omitemptyelem is specified on the parent map or slice field. + // + // Note: For pointer types (including double pointers), omitempty only omits the field if the outer pointer is nil. + // If the outer pointer is non-nil but the inner pointer is nil (e.g., **int where *int is nil), + // omitempty does not omit the field; instead, the encoder emits a DynamoDB NULL attribute for that field. elem, err := e.encode(elemVal, tag{ - OmitEmpty: fieldTag.OmitEmptyElem, - NullEmpty: fieldTag.NullEmptyElem, + OmitEmpty: fieldTag.OmitEmptyElem, + OmitEmptyElem: fieldTag.OmitEmptyElem, + NullEmpty: fieldTag.NullEmptyElem, }) if err != nil { return nil, err @@ -807,9 +829,7 @@ func encoderFieldByIndex(v reflect.Value, index []int) (reflect.Value, bool) { func valueElem(v reflect.Value) reflect.Value { switch v.Kind() { case reflect.Interface, reflect.Ptr: - for v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr { - v = v.Elem() - } + v = v.Elem() } return v diff --git a/feature/dynamodbstreams/attributevalue/encode_test.go b/feature/dynamodbstreams/attributevalue/encode_test.go index 69b3faa2f60..adb3652d53a 100644 --- a/feature/dynamodbstreams/attributevalue/encode_test.go +++ b/feature/dynamodbstreams/attributevalue/encode_test.go @@ -866,3 +866,145 @@ func TestEncodeEmptyTime(t *testing.T) { t.Errorf("expect %v, got %v", e, a) } } + +func TestEncodeDoublePointerOmitEmpty(t *testing.T) { + type input struct { + Foo **int `dynamodbav:"Foo,omitempty"` + } + var nilDouble **int + var nilSingle *int + number := 42 + nonNil := &number + + cases := []struct { + input input + expected types.AttributeValue + }{ + { + input: input{ + Foo: nil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{}, + }, + }, + { + input: input{ + Foo: nilDouble, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{}, + }, + }, + { + input: input{ + Foo: &nilSingle, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{ + Value: true, + }, + }, + }, + }, + { + input: input{ + Foo: &nonNil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberN{ + Value: "42", + }, + }, + }, + }, + } + + for i, c := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := Marshal(c.input) + if err != nil { + t.Errorf("unexpected error: %v", err) + t.Fail() + } + + if diff := cmpDiff(c.expected, actual); diff != "" { + t.Errorf("unexpected diff: %v", diff) + } + }) + } +} + +func TestEncodeDoublePointer(t *testing.T) { + type input struct { + Foo **int `dynamodbav:"Foo"` + } + var nilDouble **int + var nilSingle *int + number := 42 + nonNil := &number + + cases := []struct { + input input + expected types.AttributeValue + }{ + { + input: input{ + Foo: nil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{Value: true}, + }, + }, + }, + { + input: input{ + Foo: nilDouble, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{Value: true}, + }, + }, + }, + { + input: input{ + Foo: &nilSingle, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberNULL{Value: true}, + }, + }, + }, + { + input: input{ + Foo: &nonNil, + }, + expected: &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "Foo": &types.AttributeValueMemberN{ + Value: "42", + }, + }, + }, + }, + } + + for i, c := range cases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := Marshal(c.input) + if err != nil { + t.Errorf("unexpected error: %v", err) + t.Fail() + } + + if diff := cmpDiff(c.expected, actual); diff != "" { + t.Errorf("unexpected diff: %v", diff) + } + }) + } +}