diff --git a/internal/dynamo-browse/controllers/attrpath.go b/internal/dynamo-browse/controllers/attrpath.go new file mode 100644 index 0000000..3a653ea --- /dev/null +++ b/internal/dynamo-browse/controllers/attrpath.go @@ -0,0 +1,64 @@ +package controllers + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/pkg/errors" + "strings" +) + +type attrPath []string + +func newAttrPath(expr string) attrPath { + return strings.Split(expr, ".") +} + +func (ap attrPath) follow(item models.Item) (types.AttributeValue, error) { + var step types.AttributeValue + for i, seg := range ap { + if i == 0 { + step = item[seg] + continue + } + + switch s := step.(type) { + case *types.AttributeValueMemberM: + step = s.Value[seg] + default: + return nil, errors.Errorf("seg %v expected to be a map", i) + } + } + return step, nil +} + +func (ap attrPath) setAt(item models.Item, newValue types.AttributeValue) error { + if len(ap) == 1 { + item[ap[0]] = newValue + return nil + } + + var step types.AttributeValue + for i, seg := range ap[:len(ap)-1] { + if i == 0 { + step = item[seg] + continue + } + + switch s := step.(type) { + case *types.AttributeValueMemberM: + step = s.Value[seg] + default: + return errors.Errorf("seg %v expected to be a map", i) + } + } + + lastSeg := ap[len(ap)-1] + switch s := step.(type) { + case *types.AttributeValueMemberM: + s.Value[lastSeg] = newValue + default: + return errors.Errorf("last seg expected to be a map, but was %T", lastSeg) + } + + return nil +} diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index f94893c..acd1672 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -37,6 +37,13 @@ func (s *State) withResultSet(rs func(*models.ResultSet)) { rs(s.resultSet) } +func (s *State) withResultSetReturningError(rs func(*models.ResultSet) error) (err error) { + s.withResultSet(func(set *models.ResultSet) { + err = rs(set) + }) + return err +} + func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string) { s.mutex.Lock() defer s.mutex.Unlock() diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index d9cae53..803c4bb 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -70,7 +70,7 @@ func TestTableReadController_ExportCSV(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(controllers.NewState(), service, "alpha-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table") t.Run("should export result set to CSV file", func(t *testing.T) { tempFile := tempFile(t) @@ -83,9 +83,9 @@ func TestTableReadController_ExportCSV(t *testing.T) { assert.Equal(t, string(bts), strings.Join([]string{ "pk,sk,alpha,beta,gamma\n", - "abc,111,This is some value,,\n", "abc,222,This is another some value,1231,\n", "bbb,131,,2468,foobar\n", + "foo,bar,This is some value,,\n", }, "")) }) @@ -106,7 +106,7 @@ func TestTableReadController_Query(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(controllers.NewState(), service, "alpha-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table") t.Run("should run scan with filter based on user query", func(t *testing.T) { tempFile := tempFile(t) @@ -120,7 +120,6 @@ func TestTableReadController_Query(t *testing.T) { assert.Equal(t, string(bts), strings.Join([]string{ "pk,sk,alpha,beta\n", - "abc,111,This is some value,\n", "abc,222,This is another some value,1231\n", }, "")) }) @@ -213,6 +212,11 @@ var testData = []testdynamo.TestData{ "pk": "abc", "sk": "111", "alpha": "This is some value", + "age": 23, + "address": map[string]any{ + "no": 123, + "street": "Fake st.", + }, }, { "pk": "abc", diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 57c54e1..f6c634a 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -70,15 +70,67 @@ func (twc *TableWriteController) NewItem() tea.Cmd { func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd { return func() tea.Msg { + // Verify that the expression is valid + apPath := newAttrPath(key) + + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + _, err := apPath.follow(set.Items()[idx]) + return err + }); err != nil { + return events.Error(err) + } + return events.PromptForInputMsg{ Prompt: "string value: ", OnDone: func(value string) tea.Cmd { return func() tea.Msg { - twc.state.withResultSet(func(set *models.ResultSet) { - set.Items()[idx][key] = &types.AttributeValueMemberS{Value: value} + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + err := apPath.setAt(set.Items()[idx], &types.AttributeValueMemberS{Value: value}) + if err != nil { + return err + } + set.SetDirty(idx, true) set.RefreshColumns() - }) + return nil + }); err != nil { + return events.Error(err) + } + return ResultSetUpdated{} + } + }, + } + } +} + +func (twc *TableWriteController) SetNumberValue(idx int, key string) tea.Cmd { + return func() tea.Msg { + // Verify that the expression is valid + apPath := newAttrPath(key) + + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + _, err := apPath.follow(set.Items()[idx]) + return err + }); err != nil { + return events.Error(err) + } + + return events.PromptForInputMsg{ + Prompt: "number value: ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + err := apPath.setAt(set.Items()[idx], &types.AttributeValueMemberN{Value: value}) + if err != nil { + return err + } + + set.SetDirty(idx, true) + set.RefreshColumns() + return nil + }); err != nil { + return events.Error(err) + } return ResultSetUpdated{} } }, diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index de08093..094c706 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -1,6 +1,7 @@ package controllers_test import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" @@ -9,177 +10,6 @@ import ( "testing" ) -func TestTableWriteController_ToggleReadWrite(t *testing.T) { - t.Skip("needs to be updated") - - /* - twc, _, closeFn := setupController(t) - t.Cleanup(closeFn) - - t.Run("should enabling read write if disabled", func(t *testing.T) { - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - InReadWriteMode: false, - }) - - err := twc.ToggleReadWrite().Execute(ctx) - assert.NoError(t, err) - - assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: true}) - }) - - t.Run("should disable read write if enabled", func(t *testing.T) { - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - InReadWriteMode: true, - }) - - err := twc.ToggleReadWrite().Execute(ctx) - assert.NoError(t, err) - - assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: false}) - }) - */ -} - -func TestTableWriteController_Delete(t *testing.T) { - /* - t.Run("should delete selected item if in read/write mode is inactive", func(t *testing.T) { - twc, ctrls, closeFn := setupController(t) - t.Cleanup(closeFn) - - ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) - assert.NoError(t, err) - - resultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, resultSet.Items, 3) - - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - ResultSet: resultSet, - SelectedItem: resultSet.Items[1], - InReadWriteMode: true, - }) - - op := twc.Delete() - - // Should prompt first - err = op.Execute(ctx) - assert.NoError(t, err) - - _ = uiCtx - - */ - /* - promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) - assert.True(t, ok) - - // After prompt, continue to delete - err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "y")) - assert.NoError(t, err) - - afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, afterResultSet.Items, 2) - assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) - assert.NotContains(t, afterResultSet.Items, resultSet.Items[1]) - assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) - */ - /* - }) - - t.Run("should not delete selected item if prompt is not y", func(t *testing.T) { - twc, ctrls, closeFn := setupController(t) - t.Cleanup(closeFn) - - ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) - assert.NoError(t, err) - - resultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, resultSet.Items, 3) - - ctx, uiCtx := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - ResultSet: resultSet, - SelectedItem: resultSet.Items[1], - InReadWriteMode: true, - }) - - op := twc.Delete() - - // Should prompt first - err = op.Execute(ctx) - assert.NoError(t, err) - _ = uiCtx - */ - /* - promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) - assert.True(t, ok) - - // After prompt, continue to delete - err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "n")) - assert.Error(t, err) - - afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, afterResultSet.Items, 3) - assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) - assert.Contains(t, afterResultSet.Items, resultSet.Items[1]) - assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) - */ - /* - }) - - t.Run("should not delete if read/write mode is inactive", func(t *testing.T) { - tableWriteController, ctrls, closeFn := setupController(t) - t.Cleanup(closeFn) - - ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) - assert.NoError(t, err) - - resultSet, err := ctrls.tableService.Scan(context.Background(), ti) - assert.NoError(t, err) - assert.Len(t, resultSet.Items, 3) - - ctx, _ := testuictx.New(context.Background()) - ctx = controllers.ContextWithState(ctx, controllers.State{ - ResultSet: resultSet, - SelectedItem: resultSet.Items[1], - InReadWriteMode: false, - }) - - op := tableWriteController.Delete() - - err = op.Execute(ctx) - assert.Error(t, err) - }) - - */ -} - -/* -type controller struct { - tableName string - tableService *tables.Service -} - -func setupController(t *testing.T) (*controllers.TableWriteController, controller, func()) { - tableName := "table-write-controller-table" - - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) - provider := dynamo.NewProvider(client) - tableService := tables.NewService(provider) - tableReadController := controllers.NewTableReadController(tableService, tableName) - tableWriteController := controllers.NewTableWriteController(tableService, tableReadController) - return tableWriteController, controller{ - tableName: tableName, - tableService: tableService, - }, cleanupFn -} -*/ - func TestTableWriteController_NewItem(t *testing.T) { t.Run("should add an item with pk and sk set at the end of the result set", func(t *testing.T) { client, cleanupFn := testdynamo.SetupTestTable(t, testData) @@ -218,7 +48,7 @@ func TestTableWriteController_SetStringValue(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - t.Run("should add a new empty item at the end of the result set", func(t *testing.T) { + t.Run("should change the value of a string field if already present", func(t *testing.T) { state := controllers.NewState() readController := controllers.NewTableReadController(state, service, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) @@ -235,8 +65,73 @@ func TestTableWriteController_SetStringValue(t *testing.T) { assert.True(t, state.ResultSet().IsDirty(0)) }) - t.Run("should prevent duplicate partition,sort keys", func(t *testing.T) { - t.Skip("TODO") + t.Run("should change the value of a string field within a map if already present", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + + beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + beforeStreet := beforeAddress.Value["street"].(*types.AttributeValueMemberS).Value + + assert.Equal(t, "Fake st.", beforeStreet) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "address.street"), "Fiction rd.") + + afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + afterStreet := afterAddress.Value["street"].(*types.AttributeValueMemberS).Value + + assert.Equal(t, "Fiction rd.", afterStreet) + assert.True(t, state.ResultSet().IsDirty(0)) + }) +} + +func TestTableWriteController_SetNumberValue(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should change the value of a number field if already present", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("age") + assert.Equal(t, "23", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetNumberValue(0, "age"), "46") + + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("age") + assert.Equal(t, "46", after) + assert.True(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should change the value of a number field within a map if already present", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + + beforeAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + beforeStreet := beforeAddress.Value["no"].(*types.AttributeValueMemberN).Value + + assert.Equal(t, "123", beforeStreet) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetNumberValue(0, "address.no"), "456") + + afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + afterStreet := afterAddress.Value["no"].(*types.AttributeValueMemberN).Value + + assert.Equal(t, "456", afterStreet) + assert.True(t, state.ResultSet().IsDirty(0)) }) } diff --git a/internal/dynamo-browse/models/queryexpr/expr_test.go b/internal/dynamo-browse/models/queryexpr/expr_test.go index 21c3f3b..f3a7b4b 100644 --- a/internal/dynamo-browse/models/queryexpr/expr_test.go +++ b/internal/dynamo-browse/models/queryexpr/expr_test.go @@ -23,7 +23,7 @@ func TestModExpr_Query(t *testing.T) { modExpr, err := queryexpr.Parse(`pk="prefix"`) assert.NoError(t, err) - plan, err := modExpr.BuildQuery(tableInfo) + plan, err := modExpr.Plan(tableInfo) assert.NoError(t, err) assert.False(t, plan.CanQuery) @@ -36,7 +36,7 @@ func TestModExpr_Query(t *testing.T) { modExpr, err := queryexpr.Parse(`pk^="prefix"`) // TODO: fix this so that '^ =' is invalid assert.NoError(t, err) - plan, err := modExpr.BuildQuery(tableInfo) + plan, err := modExpr.Plan(tableInfo) assert.NoError(t, err) assert.False(t, plan.CanQuery) diff --git a/internal/dynamo-browse/providers/dynamo/provider_test.go b/internal/dynamo-browse/providers/dynamo/provider_test.go index 5d457b2..119da34 100644 --- a/internal/dynamo-browse/providers/dynamo/provider_test.go +++ b/internal/dynamo-browse/providers/dynamo/provider_test.go @@ -20,7 +20,7 @@ func TestProvider_ScanItems(t *testing.T) { t.Run("should return scanned items from the table", func(t *testing.T) { ctx := context.Background() - items, err := provider.ScanItems(ctx, tableName, 100) + items, err := provider.ScanItems(ctx, tableName, nil, 100) assert.NoError(t, err) assert.Len(t, items, 3) @@ -32,7 +32,7 @@ func TestProvider_ScanItems(t *testing.T) { t.Run("should return error if table name does not exist", func(t *testing.T) { ctx := context.Background() - items, err := provider.ScanItems(ctx, "does-not-exist", 100) + items, err := provider.ScanItems(ctx, "does-not-exist", nil, 100) assert.Error(t, err) assert.Nil(t, items) }) @@ -53,7 +53,7 @@ func TestProvider_DeleteItem(t *testing.T) { "sk": &types.AttributeValueMemberS{Value: "222"}, }) - items, err := provider.ScanItems(ctx, tableName, 100) + items, err := provider.ScanItems(ctx, tableName, nil, 100) assert.NoError(t, err) assert.Len(t, items, 2) @@ -75,7 +75,7 @@ func TestProvider_DeleteItem(t *testing.T) { "sk": &types.AttributeValueMemberS{Value: "999"}, }) - items, err := provider.ScanItems(ctx, tableName, 100) + items, err := provider.ScanItems(ctx, tableName, nil, 100) assert.NoError(t, err) assert.Len(t, items, 3) @@ -91,7 +91,7 @@ func TestProvider_DeleteItem(t *testing.T) { ctx := context.Background() - items, err := provider.ScanItems(ctx, "does-not-exist", 100) + items, err := provider.ScanItems(ctx, "does-not-exist", nil, 100) assert.Error(t, err) assert.Nil(t, items) }) diff --git a/internal/dynamo-browse/services/tables/service_test.go b/internal/dynamo-browse/services/tables/service_test.go index 3c559c7..855ef1c 100644 --- a/internal/dynamo-browse/services/tables/service_test.go +++ b/internal/dynamo-browse/services/tables/service_test.go @@ -51,7 +51,7 @@ func TestService_Scan(t *testing.T) { // Hash first, then range, then columns in alphabetic order assert.Equal(t, rs.TableInfo, ti) - assert.Equal(t, rs.Columns, []string{"pk", "sk", "alpha", "beta", "gamma"}) + assert.Equal(t, rs.Columns(), []string{"pk", "sk", "alpha", "beta", "gamma"}) //assert.Equal(t, rs.Items[0], testdynamo.TestRecordAsItem(t, testData[1])) //assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0])) //assert.Equal(t, rs.Items[2], testdynamo.TestRecordAsItem(t, testData[2])) diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 4cefc0e..891117f 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -62,6 +62,12 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon } return wc.SetStringValue(dtv.SelectedItemIndex(), args[0]) }, + "set-n": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected field")) + } + return wc.SetNumberValue(dtv.SelectedItemIndex(), args[0]) + }, "put": func(args []string) tea.Cmd { return wc.PutItem(dtv.SelectedItemIndex()) @@ -75,8 +81,7 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon }, }) - //root := layout.FullScreen(tableSelect) - root := layout.FullScreen(dialogPrompt) + root := layout.FullScreen(tableSelect) return Model{ tableReadController: rc, diff --git a/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go b/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go index 3f8c1fd..c29b63c 100644 --- a/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go @@ -14,7 +14,7 @@ func New(model layout.ResizingModel) *Model { compositor: layout.NewCompositor(model), } // TEMP - m.compositor.SetOverlay(&dialogModel{}, 5, 5, 30, 12) + //m.compositor.SetOverlay(&dialogModel{}, 5, 5, 30, 12) return m } diff --git a/test/testdynamo/client.go b/test/testdynamo/client.go index d50935c..bc23d23 100644 --- a/test/testdynamo/client.go +++ b/test/testdynamo/client.go @@ -60,11 +60,13 @@ func SetupTestTable(t *testing.T, testData []TestData) (*dynamodb.Client, func() } } - return dynamoClient, func() { + t.Cleanup(func() { for _, table := range testData { dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ TableName: aws.String(table.TableName), }) } - } + }) + + return dynamoClient, func() {} }