Added set-n command to set number attributes

Also added the ability to set subattribes of maps
This commit is contained in:
Leon Mika 2022-07-06 13:03:19 +10:00
parent ed577dc53e
commit e35855f05c
11 changed files with 223 additions and 194 deletions

View file

@ -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
}

View file

@ -37,6 +37,13 @@ func (s *State) withResultSet(rs func(*models.ResultSet)) {
rs(s.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) { func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string) {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()

View file

@ -70,7 +70,7 @@ func TestTableReadController_ExportCSV(t *testing.T) {
provider := dynamo.NewProvider(client) provider := dynamo.NewProvider(client)
service := tables.NewService(provider) 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) { t.Run("should export result set to CSV file", func(t *testing.T) {
tempFile := tempFile(t) tempFile := tempFile(t)
@ -83,9 +83,9 @@ func TestTableReadController_ExportCSV(t *testing.T) {
assert.Equal(t, string(bts), strings.Join([]string{ assert.Equal(t, string(bts), strings.Join([]string{
"pk,sk,alpha,beta,gamma\n", "pk,sk,alpha,beta,gamma\n",
"abc,111,This is some value,,\n",
"abc,222,This is another some value,1231,\n", "abc,222,This is another some value,1231,\n",
"bbb,131,,2468,foobar\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) provider := dynamo.NewProvider(client)
service := tables.NewService(provider) 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) { t.Run("should run scan with filter based on user query", func(t *testing.T) {
tempFile := tempFile(t) tempFile := tempFile(t)
@ -120,7 +120,6 @@ func TestTableReadController_Query(t *testing.T) {
assert.Equal(t, string(bts), strings.Join([]string{ assert.Equal(t, string(bts), strings.Join([]string{
"pk,sk,alpha,beta\n", "pk,sk,alpha,beta\n",
"abc,111,This is some value,\n",
"abc,222,This is another some value,1231\n", "abc,222,This is another some value,1231\n",
}, "")) }, ""))
}) })
@ -213,6 +212,11 @@ var testData = []testdynamo.TestData{
"pk": "abc", "pk": "abc",
"sk": "111", "sk": "111",
"alpha": "This is some value", "alpha": "This is some value",
"age": 23,
"address": map[string]any{
"no": 123,
"street": "Fake st.",
},
}, },
{ {
"pk": "abc", "pk": "abc",

View file

@ -70,15 +70,67 @@ func (twc *TableWriteController) NewItem() tea.Cmd {
func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd { func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd {
return func() tea.Msg { 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{ return events.PromptForInputMsg{
Prompt: "string value: ", Prompt: "string value: ",
OnDone: func(value string) tea.Cmd { OnDone: func(value string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
twc.state.withResultSet(func(set *models.ResultSet) { if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error {
set.Items()[idx][key] = &types.AttributeValueMemberS{Value: value} err := apPath.setAt(set.Items()[idx], &types.AttributeValueMemberS{Value: value})
if err != nil {
return err
}
set.SetDirty(idx, true) set.SetDirty(idx, true)
set.RefreshColumns() 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{} return ResultSetUpdated{}
} }
}, },

View file

@ -1,6 +1,7 @@
package controllers_test package controllers_test
import ( 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/controllers"
"github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/awstools/internal/dynamo-browse/services/tables"
@ -9,177 +10,6 @@ import (
"testing" "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) { 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) { 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) client, cleanupFn := testdynamo.SetupTestTable(t, testData)
@ -218,7 +48,7 @@ func TestTableWriteController_SetStringValue(t *testing.T) {
provider := dynamo.NewProvider(client) provider := dynamo.NewProvider(client)
service := tables.NewService(provider) 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() state := controllers.NewState()
readController := controllers.NewTableReadController(state, service, "alpha-table") readController := controllers.NewTableReadController(state, service, "alpha-table")
writeController := controllers.NewTableWriteController(state, service, readController) writeController := controllers.NewTableWriteController(state, service, readController)
@ -235,8 +65,73 @@ func TestTableWriteController_SetStringValue(t *testing.T) {
assert.True(t, state.ResultSet().IsDirty(0)) assert.True(t, state.ResultSet().IsDirty(0))
}) })
t.Run("should prevent duplicate partition,sort keys", func(t *testing.T) { t.Run("should change the value of a string field within a map if already present", func(t *testing.T) {
t.Skip("TODO") 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))
}) })
} }

View file

@ -23,7 +23,7 @@ func TestModExpr_Query(t *testing.T) {
modExpr, err := queryexpr.Parse(`pk="prefix"`) modExpr, err := queryexpr.Parse(`pk="prefix"`)
assert.NoError(t, err) assert.NoError(t, err)
plan, err := modExpr.BuildQuery(tableInfo) plan, err := modExpr.Plan(tableInfo)
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, plan.CanQuery) 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 modExpr, err := queryexpr.Parse(`pk^="prefix"`) // TODO: fix this so that '^ =' is invalid
assert.NoError(t, err) assert.NoError(t, err)
plan, err := modExpr.BuildQuery(tableInfo) plan, err := modExpr.Plan(tableInfo)
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, plan.CanQuery) assert.False(t, plan.CanQuery)

View file

@ -20,7 +20,7 @@ func TestProvider_ScanItems(t *testing.T) {
t.Run("should return scanned items from the table", func(t *testing.T) { t.Run("should return scanned items from the table", func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
items, err := provider.ScanItems(ctx, tableName, 100) items, err := provider.ScanItems(ctx, tableName, nil, 100)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, items, 3) 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) { t.Run("should return error if table name does not exist", func(t *testing.T) {
ctx := context.Background() 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.Error(t, err)
assert.Nil(t, items) assert.Nil(t, items)
}) })
@ -53,7 +53,7 @@ func TestProvider_DeleteItem(t *testing.T) {
"sk": &types.AttributeValueMemberS{Value: "222"}, "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.NoError(t, err)
assert.Len(t, items, 2) assert.Len(t, items, 2)
@ -75,7 +75,7 @@ func TestProvider_DeleteItem(t *testing.T) {
"sk": &types.AttributeValueMemberS{Value: "999"}, "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.NoError(t, err)
assert.Len(t, items, 3) assert.Len(t, items, 3)
@ -91,7 +91,7 @@ func TestProvider_DeleteItem(t *testing.T) {
ctx := context.Background() 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.Error(t, err)
assert.Nil(t, items) assert.Nil(t, items)
}) })

View file

@ -51,7 +51,7 @@ func TestService_Scan(t *testing.T) {
// Hash first, then range, then columns in alphabetic order // Hash first, then range, then columns in alphabetic order
assert.Equal(t, rs.TableInfo, ti) 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[0], testdynamo.TestRecordAsItem(t, testData[1]))
//assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0])) //assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0]))
//assert.Equal(t, rs.Items[2], testdynamo.TestRecordAsItem(t, testData[2])) //assert.Equal(t, rs.Items[2], testdynamo.TestRecordAsItem(t, testData[2]))

View file

@ -62,6 +62,12 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon
} }
return wc.SetStringValue(dtv.SelectedItemIndex(), args[0]) 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 { "put": func(args []string) tea.Cmd {
return wc.PutItem(dtv.SelectedItemIndex()) return wc.PutItem(dtv.SelectedItemIndex())
@ -75,8 +81,7 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon
}, },
}) })
//root := layout.FullScreen(tableSelect) root := layout.FullScreen(tableSelect)
root := layout.FullScreen(dialogPrompt)
return Model{ return Model{
tableReadController: rc, tableReadController: rc,

View file

@ -14,7 +14,7 @@ func New(model layout.ResizingModel) *Model {
compositor: layout.NewCompositor(model), compositor: layout.NewCompositor(model),
} }
// TEMP // TEMP
m.compositor.SetOverlay(&dialogModel{}, 5, 5, 30, 12) //m.compositor.SetOverlay(&dialogModel{}, 5, 5, 30, 12)
return m return m
} }

View file

@ -60,11 +60,13 @@ func SetupTestTable(t *testing.T, testData []TestData) (*dynamodb.Client, func()
} }
} }
return dynamoClient, func() { t.Cleanup(func() {
for _, table := range testData { for _, table := range testData {
dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{
TableName: aws.String(table.TableName), TableName: aws.String(table.TableName),
}) })
} }
} })
return dynamoClient, func() {}
} }