From 85dfb51988091c3e76aa7ea97334253a0120fac9 Mon Sep 17 00:00:00 2001 From: Csongor Halmai Date: Thu, 25 Jun 2026 23:15:03 +1000 Subject: [PATCH 1/5] add implementation for UpdateItem --- tests/updateitem_test.go | 188 +++++++++++++++++++++++++++++++++++++++ update_item.go | 84 +++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 tests/updateitem_test.go create mode 100644 update_item.go diff --git a/tests/updateitem_test.go b/tests/updateitem_test.go new file mode 100644 index 0000000..12fb17f --- /dev/null +++ b/tests/updateitem_test.go @@ -0,0 +1,188 @@ +package tests + +import ( + "context" + "crypto/rand" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/oolio-group/dynago" +) + +func TestUpdateItem(t *testing.T) { + ddbClient := prepareTable(t) + + partitionKey := "org_123#" + rand.Text() // avoid interference with other tests that may be running in parallel + + testCases := []struct { + name string + givePk dynago.Attribute + giveSk dynago.Attribute + giveUpdateExpr *string + giveExpressionAttributeNames map[string]*string + giveExpressionAttributeValues map[string]ddbtypes.AttributeValue + giveOptions []dynago.UpdateOption + wantAttributes map[string]ddbtypes.AttributeValue + wantErrStr string + }{ + { + name: "inserting new item and requesting ALL_OLD attributes returns no attributes", + givePk: dynago.StringValue(partitionKey), + giveSk: dynago.StringValue("2026-jan"), + giveUpdateExpr: aws.String("SET Income = :v"), + giveExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":v": &ddbtypes.AttributeValueMemberN{Value: "1000"}, + }, + giveOptions: []dynago.UpdateOption{ + dynago.WithReturnValues("ALL_OLD"), + }, + wantAttributes: map[string]ddbtypes.AttributeValue{}, + }, + { + name: "inserting new item and requesting ALL_NEW attributes returns all attributes", + givePk: dynago.StringValue(partitionKey), + giveSk: dynago.StringValue("2026-feb"), + giveUpdateExpr: aws.String("SET Income = :v"), + giveExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":v": &ddbtypes.AttributeValueMemberN{Value: "1000"}, + }, + giveOptions: []dynago.UpdateOption{ + dynago.WithReturnValues("ALL_NEW"), + }, + wantAttributes: map[string]ddbtypes.AttributeValue{ + "Income": &ddbtypes.AttributeValueMemberN{Value: "1000"}, + "pk": &ddbtypes.AttributeValueMemberS{Value: partitionKey}, + "sk": &ddbtypes.AttributeValueMemberS{Value: "2026-feb"}, + }, + }, + { + name: "updating existing item and requesting ALL_OLD attributes returns all attributes", + givePk: dynago.StringValue(partitionKey), + giveSk: dynago.StringValue("2026-jan"), + giveUpdateExpr: aws.String("SET Income = :v"), + giveExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":v": &ddbtypes.AttributeValueMemberN{Value: "2000"}, + }, + giveOptions: []dynago.UpdateOption{ + dynago.WithReturnValues("ALL_OLD"), + }, + wantAttributes: map[string]ddbtypes.AttributeValue{ + "Income": &ddbtypes.AttributeValueMemberN{Value: "1000"}, // old value is returned + "pk": &ddbtypes.AttributeValueMemberS{Value: partitionKey}, + "sk": &ddbtypes.AttributeValueMemberS{Value: "2026-jan"}, + }, + }, + { + name: "updating existing item and requesting ALL_NEW attributes returns new attributes", + givePk: dynago.StringValue(partitionKey), + giveSk: dynago.StringValue("2026-jan"), + giveUpdateExpr: aws.String("SET Income = :v"), + giveExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":v": &ddbtypes.AttributeValueMemberN{Value: "3000"}, + }, + giveOptions: []dynago.UpdateOption{ + dynago.WithReturnValues("ALL_NEW"), + }, + wantAttributes: map[string]ddbtypes.AttributeValue{ + "Income": &ddbtypes.AttributeValueMemberN{Value: "3000"}, // new value is returned + "pk": &ddbtypes.AttributeValueMemberS{Value: partitionKey}, + "sk": &ddbtypes.AttributeValueMemberS{Value: "2026-jan"}, + }, + }, + { + name: "incrementing non-existing item with ALL_NEW returns new attributes", + givePk: dynago.StringValue(partitionKey), + giveSk: dynago.StringValue("2026-mar"), + giveUpdateExpr: aws.String("ADD Income :increment"), + giveExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":increment": &ddbtypes.AttributeValueMemberN{Value: "8"}, + }, + giveOptions: []dynago.UpdateOption{ + dynago.WithReturnValues("ALL_NEW"), + }, + wantAttributes: map[string]ddbtypes.AttributeValue{ + "Income": &ddbtypes.AttributeValueMemberN{Value: "8"}, + "pk": &ddbtypes.AttributeValueMemberS{Value: partitionKey}, + "sk": &ddbtypes.AttributeValueMemberS{Value: "2026-mar"}, + }, + }, + { + name: "incrementing existing item with ALL_NEW returns new attributes", + givePk: dynago.StringValue(partitionKey), + giveSk: dynago.StringValue("2026-mar"), + giveUpdateExpr: aws.String("ADD Income :increment"), + giveExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":increment": &ddbtypes.AttributeValueMemberN{Value: "8"}, + }, + giveOptions: []dynago.UpdateOption{ + dynago.WithReturnValues("ALL_NEW"), + }, + wantAttributes: map[string]ddbtypes.AttributeValue{ + "Income": &ddbtypes.AttributeValueMemberN{Value: "16"}, + "pk": &ddbtypes.AttributeValueMemberS{Value: partitionKey}, + "sk": &ddbtypes.AttributeValueMemberS{Value: "2026-mar"}, + }, + }, + { + name: "increment missing item with condition expression causes an error", + givePk: dynago.StringValue(partitionKey), + giveSk: dynago.StringValue("2026-may"), + giveUpdateExpr: aws.String("ADD Income :increment"), + giveExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":increment": &ddbtypes.AttributeValueMemberN{Value: "8"}, + }, + giveOptions: []dynago.UpdateOption{ + dynago.WithConditionExpression("attribute_exists(pk) AND attribute_exists(sk)"), // want failure is the item does not exist + dynago.WithReturnValues("ALL_NEW"), + }, + wantAttributes: nil, + wantErrStr: "ConditionalCheckFailedException: The conditional request failed", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gotResponse, gotErr := ddbClient.UpdateItem(context.TODO(), tc.givePk, tc.giveSk, tc.giveUpdateExpr, tc.giveExpressionAttributeValues, tc.giveOptions...) + + if tc.wantErrStr != "" { + if gotErr == nil { + t.Fatalf("expected error but got nil") + } + + if !strings.Contains(gotErr.Error(), tc.wantErrStr) { + t.Fatalf("error message does not contain expected substring: got %q, want %q", gotErr.Error(), tc.wantErrStr) + } + } else { + if gotErr != nil { + t.Fatalf("unexpected error: %v", gotErr) + } + + if len(gotResponse.Attributes) != len(tc.wantAttributes) { + t.Fatalf("number of attributes does not match: got %d, want %d", len(gotResponse.Attributes), len(tc.wantAttributes)) + } + + for key, value := range tc.wantAttributes { + gotResponseValue := gotResponse.Attributes[key] + if gotResponseValue == nil { + t.Errorf("attribute %q not found in response", key) + } + + switch v := gotResponseValue.(type) { + case *ddbtypes.AttributeValueMemberN: // number + if v.Value != value.(*ddbtypes.AttributeValueMemberN).Value { + t.Errorf("attribute %q does not match: got %q, want %q", key, v.Value, value.(*ddbtypes.AttributeValueMemberN).Value) + } + case *ddbtypes.AttributeValueMemberS: // string + if v.Value != value.(*ddbtypes.AttributeValueMemberS).Value { + t.Errorf("attribute %q does not match: got %q, want %q", key, v.Value, value.(*ddbtypes.AttributeValueMemberS).Value) + } + default: + t.Errorf("unsupported attribute value type for key %q: %T", key, gotResponseValue) + } + } + } + }) + } +} diff --git a/update_item.go b/update_item.go new file mode 100644 index 0000000..bd968d6 --- /dev/null +++ b/update_item.go @@ -0,0 +1,84 @@ +package dynago + +import ( + "context" + //"fmt" + "log" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + dynamodbTypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +type UpdateOption func(*dynamodb.UpdateItemInput) error + +func WithReturnValues(returnValues string) UpdateOption { + return func(input *dynamodb.UpdateItemInput) error { + input.ReturnValues = dynamodbTypes.ReturnValue(returnValues) + return nil + } +} + +func WithConditionExpression(conditionExpression string) UpdateOption { + return func(input *dynamodb.UpdateItemInput) error { + input.ConditionExpression = &conditionExpression + return nil + } +} + +func WithReturnConsumedCapacity(returnConsumedCapacity string) UpdateOption { + return func(input *dynamodb.UpdateItemInput) error { + input.ReturnConsumedCapacity = dynamodbTypes.ReturnConsumedCapacity(returnConsumedCapacity) + return nil + } +} + +func WithReturnItemCollectionMetrics(returnItemCollectionMetrics string) UpdateOption { + return func(input *dynamodb.UpdateItemInput) error { + input.ReturnItemCollectionMetrics = dynamodbTypes.ReturnItemCollectionMetrics(returnItemCollectionMetrics) + return nil + } +} + +func WithReturnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure string) UpdateOption { + return func(input *dynamodb.UpdateItemInput) error { + input.ReturnValuesOnConditionCheckFailure = dynamodbTypes.ReturnValuesOnConditionCheckFailure(returnValuesOnConditionCheckFailure) + return nil + } +} + +// UpdateItem updates a db record from dynamodb given a partition key and sort key +// @param item the item put into the database +// @return true if the record was updated, false otherwise +func (t *Client) UpdateItem( + ctx context.Context, + pk Attribute, + sk Attribute, + updateExpression *string, + expressionAttributeValues map[string]dynamodbTypes.AttributeValue, + opts ...UpdateOption, +) (*dynamodb.UpdateItemOutput, error) { + input := &dynamodb.UpdateItemInput{ + TableName: &t.TableName, + Key: t.NewKeys(pk, sk), + UpdateExpression: updateExpression, + } + + if len(expressionAttributeValues) > 0 { + input.ExpressionAttributeValues = expressionAttributeValues + } + + // Apply option functions + if len(opts) > 0 { + for _, opt := range opts { + opt(input) + } + } + + ret, err := t.client.UpdateItem(ctx, input) + if err != nil { + log.Println("Failed to Update item" + err.Error()) + return nil, err + } + + return ret, nil +} From d239dea56f536bb9758e15fed13334b902dfe96f Mon Sep 17 00:00:00 2001 From: Csongor Halmai Date: Thu, 25 Jun 2026 23:51:38 +1000 Subject: [PATCH 2/5] address CRAI comments --- tests/updateitem_test.go | 2 +- update_item.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/updateitem_test.go b/tests/updateitem_test.go index 12fb17f..8a2cd7b 100644 --- a/tests/updateitem_test.go +++ b/tests/updateitem_test.go @@ -21,7 +21,6 @@ func TestUpdateItem(t *testing.T) { givePk dynago.Attribute giveSk dynago.Attribute giveUpdateExpr *string - giveExpressionAttributeNames map[string]*string giveExpressionAttributeValues map[string]ddbtypes.AttributeValue giveOptions []dynago.UpdateOption wantAttributes map[string]ddbtypes.AttributeValue @@ -167,6 +166,7 @@ func TestUpdateItem(t *testing.T) { gotResponseValue := gotResponse.Attributes[key] if gotResponseValue == nil { t.Errorf("attribute %q not found in response", key) + continue } switch v := gotResponseValue.(type) { diff --git a/update_item.go b/update_item.go index bd968d6..48575b8 100644 --- a/update_item.go +++ b/update_item.go @@ -70,7 +70,9 @@ func (t *Client) UpdateItem( // Apply option functions if len(opts) > 0 { for _, opt := range opts { - opt(input) + if err := opt(input); err != nil { + return nil, err + } } } From 3016a5deef2dc6f7561774aeca76f67b2e812cba Mon Sep 17 00:00:00 2001 From: Csongor Halmai Date: Fri, 26 Jun 2026 00:09:27 +1000 Subject: [PATCH 3/5] address CRAI comments --- tests/updateitem_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/updateitem_test.go b/tests/updateitem_test.go index 8a2cd7b..f70b27a 100644 --- a/tests/updateitem_test.go +++ b/tests/updateitem_test.go @@ -14,7 +14,7 @@ import ( func TestUpdateItem(t *testing.T) { ddbClient := prepareTable(t) - partitionKey := "org_123#" + rand.Text() // avoid interference with other tests that may be running in parallel + partitionKey := "org_123#" + rand.Text() // avoid interference with other tests testCases := []struct { name string @@ -143,6 +143,8 @@ func TestUpdateItem(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + // t.Parallel() // commented out: DO NOT RUN THE TESTS IN PARALLEL, because they depend on each other (e.g., one test creates an item that another test updates) + gotResponse, gotErr := ddbClient.UpdateItem(context.TODO(), tc.givePk, tc.giveSk, tc.giveUpdateExpr, tc.giveExpressionAttributeValues, tc.giveOptions...) if tc.wantErrStr != "" { From f5289bd105dc5cd74923aecc6be12b56a088790f Mon Sep 17 00:00:00 2001 From: Csongor Halmai Date: Fri, 26 Jun 2026 13:52:34 +1000 Subject: [PATCH 4/5] add UpdateItem to the WriteAPI interface; update README.md --- README.md | 20 ++++++++++++++++++++ interface.go | 1 + 2 files changed, 21 insertions(+) diff --git a/README.md b/README.md index 7d6bb0c..3d5c28f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,26 @@ func AddBalance(ctx context.Context, acc LedgerAccount, amount int) (err error) } ``` + + + +### UpdateItem + +```go +err := table.UpdateItem( + context.TODO(), + dynago.StringValue("partitionKey"), + dynago.StringValue("sortKey"), + aws.String("ADD Income :increment"), + map[string]ddbtypes.AttributeValue{ + ":increment": &ddbtypes.AttributeValueMemberN{Value: "1"}, + }, + []dynago.UpdateOption{ + dynago.WithReturnValues("ALL_NEW"), + }, +) +``` + ### Query ```go diff --git a/interface.go b/interface.go index 640044b..dee6268 100644 --- a/interface.go +++ b/interface.go @@ -28,6 +28,7 @@ type WriteAPI interface { DeleteItem(ctx context.Context, pk, sk string) error BatchDeleteItems(ctx context.Context, input []AttributeRecord) []AttributeRecord BatchPutItems(ctx context.Context, items []BatchPutItemsInput) error + UpdateItem(ctx context.Context, pk Attribute, sk Attribute, updateExpression *string, expressionAttributeValues map[string]types.AttributeValue, opts ...UpdateOption) (*dynamodb.UpdateItemOutput, error) } type TransactionAPI interface { From 602e6d7a6be3d6a818af079c5d0106e353d3434e Mon Sep 17 00:00:00 2001 From: Csongor Halmai Date: Fri, 26 Jun 2026 14:36:16 +1000 Subject: [PATCH 5/5] fix compilation error --- interface.go | 1 + 1 file changed, 1 insertion(+) diff --git a/interface.go b/interface.go index dee6268..c9f654d 100644 --- a/interface.go +++ b/interface.go @@ -4,6 +4,7 @@ import ( "context" "strconv" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" )