Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand All @@ -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).
Expand Down
8 changes: 6 additions & 2 deletions service/ddb/constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
94 changes: 49 additions & 45 deletions service/ddb/dsl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}

//
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -165,19 +189,15 @@ 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
//
// 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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Loading