From 18e179e578c02c0df94c7b18b3ed71644ca5612d Mon Sep 17 00:00:00 2001 From: Dmitry Kolesnikov Date: Sat, 21 Dec 2024 15:44:58 +0200 Subject: [PATCH 1/2] fix Invalid ConditionExpression error --- service/ddb/constraint.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/service/ddb/constraint.go b/service/ddb/constraint.go index 062e17c..59d5f7b 100644 --- a/service/ddb/constraint.go +++ b/service/ddb/constraint.go @@ -396,7 +396,9 @@ func maybeConditionExpression[T dynamo.Thing]( } } - *conditionExpression = aws.String(strings.Join(seq, " and ")) + if len(seq) > 0 { + *conditionExpression = aws.String(strings.Join(seq, " and ")) + } // Unfortunately empty maps are not accepted by DynamoDB if len(expressionAttributeNames) == 0 { @@ -429,7 +431,9 @@ func maybeUpdateConditionExpression[T dynamo.Thing]( } } - *conditionExpression = aws.String(strings.Join(seq, " and ")) + if len(seq) > 0 { + *conditionExpression = aws.String(strings.Join(seq, " and ")) + } // Unfortunately empty maps are not accepted by DynamoDB if len(expressionAttributeNames) == 0 { From 7512e8764a225a5bd8a233cb088e79d81171e714 Mon Sep 17 00:00:00 2001 From: Dmitry Kolesnikov Date: Thu, 5 Jun 2025 20:55:02 +0300 Subject: [PATCH 2/2] (fix) allow multiple commands in UpdateWith --- README.md | 63 ++++++++++++++++++++++++++++++- service/ddb/dsl.go | 94 ++++++++++++++++++++++++---------------------- 2 files changed, 111 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 36bdf12..3b547f0 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The latest version of the library is available at its `main` branch. All develop - [Projection Expression](#projection-expression) - [Conditional Expression](#conditional-expression) - [Update Expression](#update-expression) + - [Set Types](#set-types) - [Optimistic Locking](#optimistic-locking) - [Batch I/O](#batch-io) - [Configure DynamoDB](#configure-dynamodb) @@ -426,18 +427,22 @@ db.Update(/* ... */, [Update expression](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.UpdateExpressions.html) specifies how update operation will modify the attributes of an item. Unfortunately, this abstraction do not fit into the key-value concept advertised by the library. However, update expression are useful to implement counters, set management, etc. -The `dynamo` library implements `UpdateWith` method together with simple DSL. +The `dynamo` library implements `UpdateWith` method together with a simple DSL that supports all DynamoDB update expression actions: SET, REMOVE, ADD, and DELETE. + +**Basic Usage** ```go type Person struct { Name string `dynamodbav:"name,omitempty"` Age int `dynamodbav:"age,omitempty"` + Hobbies []string `dynamodbav:"hobbies,omitempty"` } // defines the builder of updater expression var ( Name = ddb.UpdateFor[Person, string]("Name") Age = ddb.UpdateFor[Person, int]("Age") + Hobbies = ddb.UpdateFor[Person, []string]("Hobbies") ) db.UpdateWith(context.Background(), @@ -452,6 +457,62 @@ db.UpdateWith(context.Background(), ) ``` +**SET** The SET action adds or replaces attributes in an item: + +```go +// Set attribute value +Name.Set("John Doe") // SET name = :value + +// Set attribute only if it doesn't exist +Name.SetNotExists("Default Name") // SET name = if_not_exists(name, :value) + +// Increment/decrement numeric values +Age.Inc(5) // SET age = age + :value +Age.Dec(2) // SET age = age - :value + +// Append to list +Hobbies.Append([]string{"reading"}) // SET hobbies = list_append(hobbies, :value) + +// Prepend to list +Hobbies.Prepend([]string{"writing"}) // SET hobbies = list_append(:value, hobbies) +``` + +**REMOVE** The REMOVE action deletes attributes from an item: + +```go +// Remove attribute entirely +Name.Remove() // REMOVE name +``` + +**Type Safety** The library provides compile-time type safety by binding update expressions to specific struct fields and their types: + +```go +// This will cause a compile error if types don't match +var Age = ddb.UpdateFor[Person, int]("age") +Age.Set("not a number") // ❌ Compile error +Age.Set(25) // ✅ Correct +``` + +#### Set Types + +The library automatically handles DynamoDB set types when the struct field has appropriate tags: + +```go +type Item struct { + StringSet []string `dynamodbav:"ss,stringset"` + NumberSet []int `dynamodbav:"ns,numberset"` + BinarySet [][]byte `dynamodbav:"bs,binaryset"` +} + +var StringSet = ddb.UpdateFor[Item, []string]("StringSet") + +// These operations work with actual DynamoDB sets +StringSet.Union([]string{"new", "items"}) // ADD ss :value +StringSet.Minus([]string{"old", "items"}) // DELETE ss :value +``` + + + ### Optimistic Locking Optimistic Locking is a lightweight approach to ensure causal ordering of read, write operations to database. AWS made a great post about [Optimistic Locking with Version Number](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBMapper.OptimisticLocking.html). diff --git a/service/ddb/dsl.go b/service/ddb/dsl.go index 9244cd9..f7d67ec 100644 --- a/service/ddb/dsl.go +++ b/service/ddb/dsl.go @@ -41,24 +41,52 @@ type UpdateItemExpression[T dynamo.Thing] struct { request *dynamodb.UpdateItemInput } +type UpdateItemInput struct { + *dynamodb.UpdateItemInput + expr map[string][]string +} + +const ( + aSET = "SET" + aREM = "REMOVE" + aADD = "ADD" + aDEL = "DELETE" +) + +// Updater creates a new update expression for the given entity and options. func Updater[T dynamo.Thing](entity T, opts ...interface{ UpdateExpression(T) }) UpdateItemExpression[T] { - request := &dynamodb.UpdateItemInput{ - ExpressionAttributeNames: map[string]string{}, - ExpressionAttributeValues: map[string]types.AttributeValue{}, + request := &UpdateItemInput{ + UpdateItemInput: &dynamodb.UpdateItemInput{ + ExpressionAttributeNames: map[string]string{}, + ExpressionAttributeValues: map[string]types.AttributeValue{}, + }, + expr: make(map[string][]string), } for _, opt := range opts { - if ap, ok := opt.(interface { - Apply(*dynamodb.UpdateItemInput) - }); ok { + if ap, ok := opt.(interface{ Apply(*UpdateItemInput) }); ok { ap.Apply(request) } } + // Combine expressions by action type + var parts []string + actionTypes := []string{aSET, aREM, aADD, aDEL} + + for _, action := range actionTypes { + if exprs, ok := request.expr[action]; ok && len(exprs) > 0 { + parts = append(parts, action+" "+strings.Join(exprs, ",")) + } + } + + if len(parts) > 0 { + request.UpdateExpression = aws.String(strings.Join(parts, " ")) + } + if len(request.ExpressionAttributeValues) == 0 { request.ExpressionAttributeValues = nil } - return UpdateItemExpression[T]{entity: entity, request: request} + return UpdateItemExpression[T]{entity: entity, request: request.UpdateItemInput} } // @@ -112,7 +140,7 @@ type updateSetter[T any, A any] struct { func (op updateSetter[T, A]) UpdateExpression(T) {} -func (op updateSetter[T, A]) Apply(req *dynamodb.UpdateItemInput) { +func (op updateSetter[T, A]) Apply(req *UpdateItemInput) { val, err := attributevalue.Marshal(op.val) if err != nil { return @@ -128,11 +156,7 @@ func (op updateSetter[T, A]) Apply(req *dynamodb.UpdateItemInput) { expr = ekey + " = if_not_exists(" + ekey + "," + eval + ")" } - if req.UpdateExpression == nil { - req.UpdateExpression = aws.String("SET " + expr) - } else { - req.UpdateExpression = aws.String(*req.UpdateExpression + "," + expr) - } + req.expr[aSET] = append(req.expr[aSET], expr) } // Add new attribute and increment value @@ -152,7 +176,7 @@ type updateAdder[T any, A any] struct { func (op updateAdder[T, A]) UpdateExpression(T) {} -func (op updateAdder[T, A]) Apply(req *dynamodb.UpdateItemInput) { +func (op updateAdder[T, A]) Apply(req *UpdateItemInput) { val, err := attributevalue.Marshal(op.val) if err != nil { return @@ -165,11 +189,7 @@ func (op updateAdder[T, A]) Apply(req *dynamodb.UpdateItemInput) { req.ExpressionAttributeValues[eval] = val expr := ekey + " " + eval - if req.UpdateExpression == nil { - req.UpdateExpression = aws.String("ADD " + expr) - } else { - req.UpdateExpression = aws.String(*req.UpdateExpression + "," + expr) - } + req.expr[aADD] = append(req.expr[aADD], expr) } // Add elements to set @@ -177,7 +197,7 @@ func (op updateAdder[T, A]) Apply(req *dynamodb.UpdateItemInput) { // name.Union(x) ⟼ ADD Field :value func (ue UpdateExpression[T, A]) Union(val A) interface{ UpdateExpression(T) } { return &updateSetOf[T, A]{ - op: "ADD", + op: aADD, setOf: ue.setOf, key: ue.key, val: val, @@ -189,7 +209,7 @@ func (ue UpdateExpression[T, A]) Union(val A) interface{ UpdateExpression(T) } { // name.Minus(x) ⟼ ADD Field :value func (ue UpdateExpression[T, A]) Minus(val A) interface{ UpdateExpression(T) } { return &updateSetOf[T, A]{ - op: "DELETE", + op: aDEL, setOf: ue.setOf, key: ue.key, val: val, @@ -205,7 +225,7 @@ type updateSetOf[T any, A any] struct { func (op updateSetOf[T, A]) UpdateExpression(T) {} -func (op updateSetOf[T, A]) Apply(req *dynamodb.UpdateItemInput) { +func (op updateSetOf[T, A]) Apply(req *UpdateItemInput) { val, err := op.encodeValue() if err != nil { return @@ -218,11 +238,7 @@ func (op updateSetOf[T, A]) Apply(req *dynamodb.UpdateItemInput) { req.ExpressionAttributeValues[eval] = val expr := ekey + " " + eval - if req.UpdateExpression == nil { - req.UpdateExpression = aws.String(op.op + " " + expr) - } else { - req.UpdateExpression = aws.String(*req.UpdateExpression + "," + expr) - } + req.expr[op.op] = append(req.expr[op.op], expr) } func (op updateSetOf[T, A]) encodeValue() (types.AttributeValue, error) { @@ -315,7 +331,7 @@ type updateIncrement[T any, A any] struct { func (op updateIncrement[T, A]) UpdateExpression(T) {} -func (op updateIncrement[T, A]) Apply(req *dynamodb.UpdateItemInput) { +func (op updateIncrement[T, A]) Apply(req *UpdateItemInput) { val, err := attributevalue.Marshal(op.val) if err != nil { return @@ -327,11 +343,7 @@ func (op updateIncrement[T, A]) Apply(req *dynamodb.UpdateItemInput) { req.ExpressionAttributeNames[ekey] = op.key req.ExpressionAttributeValues[eval] = val - if req.UpdateExpression == nil { - req.UpdateExpression = aws.String("SET " + ekey + " = " + ekey + op.op + eval) - } else { - req.UpdateExpression = aws.String(*req.UpdateExpression + "," + ekey + " = " + ekey + op.op + eval) - } + req.expr[aSET] = append(req.expr[aSET], ekey+" = "+ekey+op.op+eval) } // Append element to list @@ -356,7 +368,7 @@ type updateAppender[T any, A any] struct { func (op updateAppender[T, A]) UpdateExpression(T) {} -func (op updateAppender[T, A]) Apply(req *dynamodb.UpdateItemInput) { +func (op updateAppender[T, A]) Apply(req *UpdateItemInput) { val, err := attributevalue.Marshal(op.val) if err != nil { return @@ -375,11 +387,7 @@ func (op updateAppender[T, A]) Apply(req *dynamodb.UpdateItemInput) { cmd = "list_append(" + eval + "," + ekey + ")" } - if req.UpdateExpression == nil { - req.UpdateExpression = aws.String("SET " + ekey + " = " + cmd) - } else { - req.UpdateExpression = aws.String(*req.UpdateExpression + "," + ekey + " = " + cmd) - } + req.expr[aSET] = append(req.expr[aSET], ekey+" = "+cmd) } // Remove attribute @@ -395,14 +403,10 @@ type updateRemover[T any] struct { func (op updateRemover[T]) UpdateExpression(T) {} -func (op updateRemover[T]) Apply(req *dynamodb.UpdateItemInput) { +func (op updateRemover[T]) Apply(req *UpdateItemInput) { ekey := "#__" + op.key + "__" req.ExpressionAttributeNames[ekey] = op.key - if req.UpdateExpression == nil { - req.UpdateExpression = aws.String("REMOVE " + ekey) - } else { - req.UpdateExpression = aws.String(*req.UpdateExpression + "," + ekey) - } + req.expr[aREM] = append(req.expr[aREM], ekey) }