diff --git a/cloudemu_test.go b/cloudemu_test.go index 31c0efef..69233a3a 100644 --- a/cloudemu_test.go +++ b/cloudemu_test.go @@ -5570,3 +5570,401 @@ func TestGCPMetricsEmission(t *testing.T) { } }) } + +func TestUpdateItemAWS(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "users", PartitionKey: "pk", SortKey: "sk", + }); err != nil { + t.Fatal(err) + } + + // Put initial item + if err := p.DynamoDB.PutItem(ctx, "users", map[string]any{ + "pk": "user1", "sk": "profile", "name": "Alice", "age": 30, "city": "NYC", + }); err != nil { + t.Fatal(err) + } + + // SET: update name and add new field + updated, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected 'Alice Smith', got %v", updated["name"]) + } + + if updated["email"] != "alice@example.com" { + t.Errorf("expected 'alice@example.com', got %v", updated["email"]) + } + + if updated["age"] != 30 { + t.Errorf("expected age 30 preserved, got %v", updated["age"]) + } + + // REMOVE: remove city field + updated, err = p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if _, hasCityField := updated["city"]; hasCityField { + t.Error("expected city field to be removed") + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected name preserved as 'Alice Smith', got %v", updated["name"]) + } + + // Verify via GetItem + got, err := p.DynamoDB.GetItem(ctx, "users", map[string]any{"pk": "user1", "sk": "profile"}) + if err != nil { + t.Fatal(err) + } + + if got["name"] != "Alice Smith" { + t.Errorf("GetItem: expected 'Alice Smith', got %v", got["name"]) + } + + if got["email"] != "alice@example.com" { + t.Errorf("GetItem: expected 'alice@example.com', got %v", got["email"]) + } + + if _, hasCityField := got["city"]; hasCityField { + t.Error("GetItem: expected city field to be removed") + } +} + +func TestUpdateItemAzure(t *testing.T) { + ctx := context.Background() + p := NewAzure() + + if err := p.CosmosDB.CreateTable(ctx, driver.TableConfig{ + Name: "users", PartitionKey: "pk", SortKey: "sk", + }); err != nil { + t.Fatal(err) + } + + if err := p.CosmosDB.PutItem(ctx, "users", map[string]any{ + "pk": "user1", "sk": "profile", "name": "Alice", "age": 30, "city": "NYC", + }); err != nil { + t.Fatal(err) + } + + // SET fields + updated, err := p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected 'Alice Smith', got %v", updated["name"]) + } + + if updated["email"] != "alice@example.com" { + t.Errorf("expected 'alice@example.com', got %v", updated["email"]) + } + + if updated["age"] != 30 { + t.Errorf("expected age 30 preserved, got %v", updated["age"]) + } + + // REMOVE field + updated, err = p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if _, hasCityField := updated["city"]; hasCityField { + t.Error("expected city field to be removed") + } + + // Verify via GetItem + got, err := p.CosmosDB.GetItem(ctx, "users", map[string]any{"pk": "user1", "sk": "profile"}) + if err != nil { + t.Fatal(err) + } + + if got["name"] != "Alice Smith" { + t.Errorf("GetItem: expected 'Alice Smith', got %v", got["name"]) + } +} + +func TestUpdateItemGCP(t *testing.T) { + ctx := context.Background() + p := NewGCP() + + if err := p.Firestore.CreateTable(ctx, driver.TableConfig{ + Name: "users", PartitionKey: "pk", SortKey: "sk", + }); err != nil { + t.Fatal(err) + } + + if err := p.Firestore.PutItem(ctx, "users", map[string]any{ + "pk": "user1", "sk": "profile", "name": "Alice", "age": 30, "city": "NYC", + }); err != nil { + t.Fatal(err) + } + + // SET fields + updated, err := p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if updated["name"] != "Alice Smith" { + t.Errorf("expected 'Alice Smith', got %v", updated["name"]) + } + + if updated["email"] != "alice@example.com" { + t.Errorf("expected 'alice@example.com', got %v", updated["email"]) + } + + if updated["age"] != 30 { + t.Errorf("expected age 30 preserved, got %v", updated["age"]) + } + + // REMOVE field + updated, err = p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "user1", "sk": "profile"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if _, hasCityField := updated["city"]; hasCityField { + t.Error("expected city field to be removed") + } + + // Verify via GetItem + got, err := p.Firestore.GetItem(ctx, "users", map[string]any{"pk": "user1", "sk": "profile"}) + if err != nil { + t.Fatal(err) + } + + if got["name"] != "Alice Smith" { + t.Errorf("GetItem: expected 'Alice Smith', got %v", got["name"]) + } +} + +func TestUpdateItemNotFound(t *testing.T) { + ctx := context.Background() + + t.Run("AWS", func(t *testing.T) { + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("Azure", func(t *testing.T) { + p := NewAzure() + + if err := p.CosmosDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + _, err := p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("GCP", func(t *testing.T) { + p := NewGCP() + + if err := p.Firestore.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + _, err := p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) +} + +func TestUpdateItemTableNotFound(t *testing.T) { + ctx := context.Background() + + t.Run("AWS", func(t *testing.T) { + p := NewAWS() + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("Azure", func(t *testing.T) { + p := NewAzure() + + _, err := p.CosmosDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) + + t.Run("GCP", func(t *testing.T) { + p := NewGCP() + + _, err := p.Firestore.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "x", Value: 1}}, + }) + if !cerrors.IsNotFound(err) { + t.Errorf("expected NotFound, got %v", err) + } + }) +} + +func TestUpdateItemInvalidAction(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + if err := p.DynamoDB.PutItem(ctx, "t1", map[string]any{"pk": "k1", "v": 1}); err != nil { + t.Fatal(err) + } + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "k1"}, + Actions: []driver.UpdateAction{{Action: "INVALID", Field: "v", Value: 2}}, + }) + if err == nil { + t.Error("expected error for invalid action, got nil") + } +} + +func TestUpdateItemWithStreams(t *testing.T) { + ctx := context.Background() + p := NewAWS() + + if err := p.DynamoDB.CreateTable(ctx, driver.TableConfig{ + Name: "t1", PartitionKey: "pk", + }); err != nil { + t.Fatal(err) + } + + if err := p.DynamoDB.UpdateStreamConfig(ctx, "t1", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + }); err != nil { + t.Fatal(err) + } + + if err := p.DynamoDB.PutItem(ctx, "t1", map[string]any{"pk": "k1", "val": "old"}); err != nil { + t.Fatal(err) + } + + _, err := p.DynamoDB.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "t1", + Key: map[string]any{"pk": "k1"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "val", Value: "new"}}, + }) + if err != nil { + t.Fatal(err) + } + + iter, err := p.DynamoDB.GetStreamRecords(ctx, "t1", 10, "") + if err != nil { + t.Fatal(err) + } + + // Should have INSERT (from PutItem) + MODIFY (from UpdateItem) + if len(iter.Records) != 2 { + t.Fatalf("expected 2 stream records, got %d", len(iter.Records)) + } + + modifyRec := iter.Records[1] + if modifyRec.EventType != "MODIFY" { + t.Errorf("expected MODIFY event, got %s", modifyRec.EventType) + } + + if modifyRec.OldImage["val"] != "old" { + t.Errorf("expected old image val='old', got %v", modifyRec.OldImage["val"]) + } + + if modifyRec.NewImage["val"] != "new" { + t.Errorf("expected new image val='new', got %v", modifyRec.NewImage["val"]) + } +} diff --git a/database/database.go b/database/database.go index de67ba24..40ccdf4f 100644 --- a/database/database.go +++ b/database/database.go @@ -135,6 +135,22 @@ func (db *Database) GetItem(ctx context.Context, table string, key map[string]an return out.(map[string]any), nil } +func (db *Database) UpdateItem(ctx context.Context, input driver.UpdateItemInput) (map[string]any, error) { + out, err := db.do(ctx, "UpdateItem", map[string]any{"table": input.Table}, func() (any, error) { + return db.driver.UpdateItem(ctx, input) + }) + + if err != nil { + return nil, err + } + + if out == nil { + return nil, nil + } + + return out.(map[string]any), nil +} + func (db *Database) DeleteItem(ctx context.Context, table string, key map[string]any) error { _, err := db.do(ctx, "DeleteItem", map[string]any{"table": table}, func() (any, error) { return nil, db.driver.DeleteItem(ctx, table, key) diff --git a/database/driver/driver.go b/database/driver/driver.go index c0028bf3..b7dc29d3 100644 --- a/database/driver/driver.go +++ b/database/driver/driver.go @@ -52,6 +52,20 @@ type GSIConfig struct { SortKey string } +// UpdateAction represents a single field-level update action. +type UpdateAction struct { + Action string // "SET" or "REMOVE" + Field string + Value any // ignored for REMOVE +} + +// UpdateItemInput describes an update operation on an existing item. +type UpdateItemInput struct { + Table string + Key map[string]any + Actions []UpdateAction +} + // KeyCondition defines a key condition for queries. type KeyCondition struct { PartitionKey string @@ -102,6 +116,7 @@ type Database interface { PutItem(ctx context.Context, table string, item map[string]any) error GetItem(ctx context.Context, table string, key map[string]any) (map[string]any, error) + UpdateItem(ctx context.Context, input UpdateItemInput) (map[string]any, error) DeleteItem(ctx context.Context, table string, key map[string]any) error Query(ctx context.Context, input QueryInput) (*QueryResult, error) Scan(ctx context.Context, input ScanInput) (*QueryResult, error) diff --git a/providers/aws/dynamodb/dynamodb.go b/providers/aws/dynamodb/dynamodb.go index 65c64f3a..8f8dc232 100644 --- a/providers/aws/dynamodb/dynamodb.go +++ b/providers/aws/dynamodb/dynamodb.go @@ -190,6 +190,50 @@ func (m *Mock) GetItem(_ context.Context, table string, key map[string]any) (map return item, nil } +// UpdateItem applies partial updates to an existing item. +func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[string]any, error) { + m.mu.Lock() + + td, exists := m.tables[input.Table] + if !exists { + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.NotFound, "table %s not found", input.Table) + } + + k := itemKey(td.config, input.Key) + item, ok := td.items.Get(k) + + if !ok { + m.mu.Unlock() + return nil, cerrors.New(cerrors.NotFound, "item not found") + } + + oldItem := copyItem(item) + updated := copyItem(item) + + for _, action := range input.Actions { + switch action.Action { + case "SET": + updated[action.Field] = action.Value + case "REMOVE": + delete(updated, action.Field) + default: + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported action: %s", action.Action) + } + } + + td.items.Set(k, updated) + m.recordStreamEvent(td, oldItem, updated, true) + m.mu.Unlock() + + dims := map[string]string{"TableName": input.Table} + m.emitMetric("ConsumedWriteCapacityUnits", 1, dims) + m.emitMetric("SuccessfulRequestCount", 1, dims) + + return updated, nil +} + func (m *Mock) DeleteItem(_ context.Context, table string, key map[string]any) error { m.mu.Lock() diff --git a/providers/aws/dynamodb/dynamodb_test.go b/providers/aws/dynamodb/dynamodb_test.go index 51e5a4e2..36efbcd8 100644 --- a/providers/aws/dynamodb/dynamodb_test.go +++ b/providers/aws/dynamodb/dynamodb_test.go @@ -714,3 +714,193 @@ func assertNotEmpty(t *testing.T, s string) { t.Error("expected non-empty string") } } + +func TestUpdateItemSetFields(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "name": "Alice", "age": 30}) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@test.com"}, + }, + }) + requireNoError(t, err) + assertEqual(t, "Alice Smith", updated["name"]) + assertEqual(t, "alice@test.com", updated["email"]) + assertEqual(t, 30, updated["age"]) +} + +func TestUpdateItemRemoveFields(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "name": "Alice", "city": "NYC"}) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + requireNoError(t, err) + + if _, has := updated["city"]; has { + t.Error("expected city to be removed") + } + + assertEqual(t, "Alice", updated["name"]) +} + +func TestUpdateItemSetAndRemoveCombined(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "name": "Alice", "old_field": "remove_me"}) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Bob"}, + {Action: "REMOVE", Field: "old_field"}, + }, + }) + requireNoError(t, err) + assertEqual(t, "Bob", updated["name"]) + + if _, has := updated["old_field"]; has { + t.Error("expected old_field to be removed") + } +} + +func TestUpdateItemPersistsChanges(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "v": "old"}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "new"}, + }, + }) + requireNoError(t, err) + + got, err := m.GetItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info"}) + requireNoError(t, err) + assertEqual(t, "new", got["v"]) +} + +func TestUpdateItemTableNotFound(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + assertError(t, err, true) +} + +func TestUpdateItemItemNotFound(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "missing", "sk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + assertError(t, err, true) +} + +func TestUpdateItemInvalidAction(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "v": 1}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{{Action: "ADD", Field: "v", Value: 1}}, + }) + assertError(t, err, true) +} + +func TestUpdateItemEmitsStreamRecord(t *testing.T) { + m := newTestMock() + ctx := context.Background() + createTestTable(m, "tbl") + + _ = m.UpdateStreamConfig(ctx, "tbl", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + }) + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info", "val": "old"}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "val", Value: "new"}, + }, + }) + requireNoError(t, err) + + iter, err := m.GetStreamRecords(ctx, "tbl", 10, "") + requireNoError(t, err) + assertEqual(t, 2, len(iter.Records)) + assertEqual(t, "MODIFY", iter.Records[1].EventType) + assertEqual(t, "old", iter.Records[1].OldImage["val"]) + assertEqual(t, "new", iter.Records[1].NewImage["val"]) +} + +func TestUpdateItemEmitsMetrics(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc)) + m := New(opts) + ctx := context.Background() + + cw := cloudwatch.New(opts) + m.SetMonitoring(cw) + createTestTable(m, "tbl") + + _ = m.PutItem(ctx, "tbl", map[string]any{"pk": "u1", "sk": "info"}) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "tbl", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "x"}, + }, + }) + requireNoError(t, err) + + result, err := cw.GetMetricData(ctx, mondriver.GetMetricInput{ + Namespace: "AWS/DynamoDB", + MetricName: "ConsumedWriteCapacityUnits", + Dimensions: map[string]string{"TableName": "tbl"}, + StartTime: fc.Now().Add(-1 * time.Hour), + EndTime: fc.Now().Add(1 * time.Hour), + Period: 60, + Stat: "Sum", + }) + requireNoError(t, err) + assertEqual(t, true, len(result.Values) > 0) +} diff --git a/providers/azure/cosmosdb/cosmosdb.go b/providers/azure/cosmosdb/cosmosdb.go index 3bb8d9c1..51699615 100644 --- a/providers/azure/cosmosdb/cosmosdb.go +++ b/providers/azure/cosmosdb/cosmosdb.go @@ -206,6 +206,48 @@ func (m *Mock) GetItem(_ context.Context, table string, key map[string]any) (map return item, nil } +// UpdateItem applies partial updates to an existing document in a container. +func (m *Mock) UpdateItem(_ context.Context, input driver.UpdateItemInput) (map[string]any, error) { + m.mu.Lock() + + td, exists := m.tables[input.Table] + if !exists { + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.NotFound, "container %s not found", input.Table) + } + + k := itemKey(td.config, input.Key) + item, ok := td.items.Get(k) + + if !ok { + m.mu.Unlock() + return nil, cerrors.New(cerrors.NotFound, "item not found") + } + + oldItem := copyItem(item) + updated := copyItem(item) + + for _, action := range input.Actions { + switch action.Action { + case "SET": + updated[action.Field] = action.Value + case "REMOVE": + delete(updated, action.Field) + default: + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported action: %s", action.Action) + } + } + + td.items.Set(k, updated) + m.recordStreamEvent(td, oldItem, updated, true) + m.mu.Unlock() + + m.emitMetric(input.Table, map[string]float64{"TotalRequests": 1, "TotalRequestUnits": 1}) + + return updated, nil +} + // DeleteItem deletes an item from a container by key. func (m *Mock) DeleteItem(_ context.Context, table string, key map[string]any) error { m.mu.Lock() diff --git a/providers/azure/cosmosdb/cosmosdb_test.go b/providers/azure/cosmosdb/cosmosdb_test.go index f779d26c..aa6db9e0 100644 --- a/providers/azure/cosmosdb/cosmosdb_test.go +++ b/providers/azure/cosmosdb/cosmosdb_test.go @@ -687,3 +687,187 @@ func (c *cosmosMetricsCollector) hasMetric(namespace, metricName string) bool { } return false } + +func TestUpdateItemSetFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "age": 30, + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@test.com"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Alice Smith", updated["name"]) + assert.Equal(t, "alice@test.com", updated["email"]) + assert.Equal(t, 30, updated["age"]) +} + +func TestUpdateItemRemoveFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "city": "NYC", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + require.NoError(t, err) + _, hasCityField := updated["city"] + assert.False(t, hasCityField, "expected city to be removed") + assert.Equal(t, "Alice", updated["name"]) +} + +func TestUpdateItemSetAndRemoveCombined(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "old_field": "remove_me", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Bob"}, + {Action: "REMOVE", Field: "old_field"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Bob", updated["name"]) + _, hasOld := updated["old_field"] + assert.False(t, hasOld, "expected old_field to be removed") +} + +func TestUpdateItemPersistsChanges(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{ + "pk": "u1", "sk": "info", "v": "old", + })) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "new"}, + }, + }) + require.NoError(t, err) + + got, err := m.GetItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info"}) + require.NoError(t, err) + assert.Equal(t, "new", got["v"]) +} + +func TestUpdateItemTableNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemItemNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "missing", "sk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemInvalidAction(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info", "v": 1})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{{Action: "ADD", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} + +func TestUpdateItemEmitsStreamRecord(t *testing.T) { + ctx := context.Background() + m := newTestMock() + createTestTable(t, m) + + require.NoError(t, m.UpdateStreamConfig(ctx, "users", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + })) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info", "val": "old"})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "val", Value: "new"}, + }, + }) + require.NoError(t, err) + + iter, err := m.GetStreamRecords(ctx, "users", 10, "") + require.NoError(t, err) + require.Len(t, iter.Records, 2) + assert.Equal(t, "MODIFY", iter.Records[1].EventType) + assert.Equal(t, "old", iter.Records[1].OldImage["val"]) + assert.Equal(t, "new", iter.Records[1].NewImage["val"]) +} + +func TestUpdateItemEmitsMetrics(t *testing.T) { + ctx := context.Background() + m := newTestMock() + mon := &cosmosMetricsCollector{} + m.SetMonitoring(mon) + createTestTable(t, m) + + require.NoError(t, m.PutItem(ctx, "users", map[string]any{"pk": "u1", "sk": "info"})) + + mon.reset() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "users", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "x"}, + }, + }) + require.NoError(t, err) + assert.True(t, mon.hasMetric("Microsoft.DocumentDB/databaseAccounts", "TotalRequests")) +} diff --git a/providers/gcp/firestore/firestore.go b/providers/gcp/firestore/firestore.go index ee745629..f29f64ea 100644 --- a/providers/gcp/firestore/firestore.go +++ b/providers/gcp/firestore/firestore.go @@ -194,6 +194,48 @@ func (m *Mock) GetItem(ctx context.Context, table string, key map[string]any) (m return item, nil } +// UpdateItem applies partial updates to an existing document in a collection. +func (m *Mock) UpdateItem(ctx context.Context, input driver.UpdateItemInput) (map[string]any, error) { + m.mu.Lock() + + cd, exists := m.collections[input.Table] + if !exists { + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.NotFound, "collection %s not found", input.Table) + } + + k := docKey(cd.config, input.Key) + item, ok := cd.items.Get(k) + + if !ok { + m.mu.Unlock() + return nil, cerrors.New(cerrors.NotFound, "document not found") + } + + oldItem := copyItem(item) + updated := copyItem(item) + + for _, action := range input.Actions { + switch action.Action { + case "SET": + updated[action.Field] = action.Value + case "REMOVE": + delete(updated, action.Field) + default: + m.mu.Unlock() + return nil, cerrors.Newf(cerrors.InvalidArgument, "unsupported action: %s", action.Action) + } + } + + cd.items.Set(k, updated) + m.recordStreamEvent(cd, oldItem, updated, true) + m.mu.Unlock() + + m.emitMetric(ctx, "document/write_count", 1, map[string]string{"collection_id": input.Table}) + + return updated, nil +} + func (m *Mock) DeleteItem(ctx context.Context, table string, key map[string]any) error { m.mu.Lock() diff --git a/providers/gcp/firestore/firestore_test.go b/providers/gcp/firestore/firestore_test.go index f29e7a82..4f3245a9 100644 --- a/providers/gcp/firestore/firestore_test.go +++ b/providers/gcp/firestore/firestore_test.go @@ -954,3 +954,192 @@ func TestScanUnsupportedFilter(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, result.Count) } + +func TestUpdateItemSetFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "age": 30, + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Alice Smith"}, + {Action: "SET", Field: "email", Value: "alice@test.com"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Alice Smith", updated["name"]) + assert.Equal(t, "alice@test.com", updated["email"]) + assert.Equal(t, 30, updated["age"]) +} + +func TestUpdateItemRemoveFields(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "city": "NYC", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "REMOVE", Field: "city"}, + }, + }) + require.NoError(t, err) + _, hasCityField := updated["city"] + assert.False(t, hasCityField, "expected city to be removed") + assert.Equal(t, "Alice", updated["name"]) +} + +func TestUpdateItemSetAndRemoveCombined(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "name": "Alice", "old_field": "remove_me", + })) + + updated, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "name", Value: "Bob"}, + {Action: "REMOVE", Field: "old_field"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "Bob", updated["name"]) + _, hasOld := updated["old_field"] + assert.False(t, hasOld, "expected old_field to be removed") +} + +func TestUpdateItemPersistsChanges(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{ + "pk": "u1", "sk": "info", "v": "old", + })) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "new"}, + }, + }) + require.NoError(t, err) + + got, err := m.GetItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info"}) + require.NoError(t, err) + assert.Equal(t, "new", got["v"]) +} + +func TestUpdateItemCollectionNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "nonexistent", + Key: map[string]any{"pk": "x"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemDocumentNotFound(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "missing", "sk": "missing"}, + Actions: []driver.UpdateAction{{Action: "SET", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateItemInvalidAction(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info", "v": 1})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{{Action: "ADD", Field: "v", Value: 1}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} + +func TestUpdateItemEmitsStreamRecord(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.UpdateStreamConfig(ctx, "col", driver.StreamConfig{ + Enabled: true, ViewType: "NEW_AND_OLD_IMAGES", + })) + + require.NoError(t, m.PutItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info", "val": "old"})) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "val", Value: "new"}, + }, + }) + require.NoError(t, err) + + iter, err := m.GetStreamRecords(ctx, "col", 10, "") + require.NoError(t, err) + require.Len(t, iter.Records, 2) + assert.Equal(t, "MODIFY", iter.Records[1].EventType) + assert.Equal(t, "old", iter.Records[1].OldImage["val"]) + assert.Equal(t, "new", iter.Records[1].NewImage["val"]) +} + +func TestUpdateItemEmitsMetrics(t *testing.T) { + ctx := context.Background() + clk := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(clk), config.WithProjectID("test-project")) + + mon := &firestoreMonMock{data: make(map[string][]mondriver.MetricDatum)} + m := New(opts) + m.SetMonitoring(mon) + + require.NoError(t, m.CreateTable(ctx, driver.TableConfig{Name: "col", PartitionKey: "pk", SortKey: "sk"})) + require.NoError(t, m.PutItem(ctx, "col", map[string]any{"pk": "u1", "sk": "info"})) + + // Clear metrics from PutItem + mon.data = make(map[string][]mondriver.MetricDatum) + + _, err := m.UpdateItem(ctx, driver.UpdateItemInput{ + Table: "col", + Key: map[string]any{"pk": "u1", "sk": "info"}, + Actions: []driver.UpdateAction{ + {Action: "SET", Field: "v", Value: "x"}, + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, mon.data["firestore.googleapis.com/document/write_count"]) +}