sqs-browse: started working on put commands

This commit is contained in:
Leon Mika 2022-03-24 12:54:32 +11:00
parent 43680000a8
commit cecdbafabb
17 changed files with 339 additions and 26 deletions

View file

@ -0,0 +1,48 @@
package commandctrl
import (
"context"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/shellwords"
"github.com/pkg/errors"
"strings"
)
type CommandController struct {
commands map[string]uimodels.Operation
}
func NewCommandController(commands map[string]uimodels.Operation) *CommandController {
return &CommandController{
commands: commands,
}
}
func (c *CommandController) Prompt() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
uiCtx.Send(events.PromptForInput{
Prompt: ":",
OnDone: c.Execute(),
})
return nil
})
}
func (c *CommandController) Execute() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
input := strings.TrimSpace(uimodels.PromptValue(ctx))
if input == "" {
return nil
}
tokens := shellwords.Split(input)
command, ok := c.commands[tokens[0]]
if !ok {
return errors.New("no such command: " + tokens[0])
}
return command.Execute(WithCommandArgs(ctx, tokens[1:]))
})
}

View file

@ -0,0 +1,25 @@
package commandctrl_test
import (
"context"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/test/testuictx"
"github.com/stretchr/testify/assert"
"testing"
)
func TestCommandController_Prompt(t *testing.T) {
t.Run("prompt user for a command", func(t *testing.T) {
cmd := commandctrl.NewCommandController()
ctx, uiCtx := testuictx.New(context.Background())
err := cmd.Prompt().Execute(ctx)
assert.NoError(t, err)
promptMsg, ok := uiCtx.Messages[0].(events.PromptForInput)
assert.True(t, ok)
assert.Equal(t, ":", promptMsg.Prompt)
})
}

View file

@ -0,0 +1,15 @@
package commandctrl
import "context"
type commandArgContextKeyType struct {}
var commandArgContextKey = commandArgContextKeyType{}
func WithCommandArgs(ctx context.Context, args []string) context.Context {
return context.WithValue(ctx, commandArgContextKey, args)
}
func CommandArgs(ctx context.Context) []string {
args, _ := ctx.Value(commandArgContextKey).([]string)
return args
}

View file

@ -3,6 +3,7 @@ package controllers
import (
"context"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/awstools/internal/dynamo-browse/models/modexpr"
"github.com/lmika/awstools/internal/dynamo-browse/services/tables"
"github.com/pkg/errors"
)
@ -21,12 +22,67 @@ func NewTableWriteController(tableService *tables.Service, tableReadControllers
}
}
func (c *TableWriteController) EnableReadWrite() uimodels.Operation {
func (c *TableWriteController) ToggleReadWrite() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
uiCtx.Send(SetReadWrite{NewValue: true})
uiCtx.Message("read/write mode enabled")
state := CurrentState(ctx)
if state.InReadWriteMode {
uiCtx.Send(SetReadWrite{NewValue: false})
uiCtx.Message("read/write mode disabled")
} else {
uiCtx.Send(SetReadWrite{NewValue: true})
uiCtx.Message("read/write mode enabled")
}
return nil
})
}
func (c *TableWriteController) Duplicate() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
state := CurrentState(ctx)
if state.SelectedItem == nil {
return errors.New("no selected item")
} else if !state.InReadWriteMode {
return errors.New("not in read/write mode")
}
uiCtx.Input("Dup: ", uimodels.OperationFn(func(ctx context.Context) error {
modExpr, err := modexpr.Parse(uimodels.PromptValue(ctx))
if err != nil {
return err
}
newItem, err := modExpr.Patch(state.SelectedItem)
if err != nil {
return err
}
// TODO: preview new item
uiCtx := uimodels.Ctx(ctx)
uiCtx.Input("Put item? ", uimodels.OperationFn(func(ctx context.Context) error {
if uimodels.PromptValue(ctx) != "y" {
return errors.New("operation aborted")
}
// Delete the item
if err := c.tableService.Put(ctx, c.tableName, newItem); err != nil {
return err
}
// Rescan to get updated items
if err := c.tableReadControllers.doScan(ctx, true); err != nil {
return err
}
return nil
}))
return nil
}))
return nil
})
}

View file

@ -13,21 +13,33 @@ import (
"testing"
)
func TestTableWriteController_EnableReadWrite(t *testing.T) {
func TestTableWriteController_ToggleReadWrite(t *testing.T) {
twc, _, closeFn := setupController(t)
t.Cleanup(closeFn)
t.Run("should send event enabling read write", func(t *testing.T) {
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.EnableReadWrite().Execute(ctx)
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) {

View file

@ -9,3 +9,15 @@ type ResultSet struct {
}
type Item map[string]types.AttributeValue
// Clone creates a clone of the current item
func (i Item) Clone() Item {
newItem := Item{}
// TODO: should be a deep clone?
for k, v := range i {
newItem[k] = v
}
return newItem
}

View file

@ -0,0 +1,39 @@
package modexpr
import (
"github.com/alecthomas/participle/v2"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/pkg/errors"
"strconv"
)
type astExpr struct {
Attributes []*astAttribute `parser:"@@ (',' @@)*"`
}
type astAttribute struct {
Name string `parser:"@Ident '='"`
Value string `parser:"@String"`
}
func (a astAttribute) dynamoValue() (types.AttributeValue, error) {
// TODO: should be based on type
s, err := strconv.Unquote(a.Value)
if err != nil {
return nil, errors.Wrap(err, "cannot unquote string")
}
return &types.AttributeValueMemberS{Value: s}, nil
}
var parser = participle.MustBuild(&astExpr{})
func Parse(expr string) (*ModExpr, error) {
var ast astExpr
if err := parser.ParseString("expr", expr, &ast); err != nil {
return nil, errors.Wrapf(err, "cannot parse expression: '%v'", expr)
}
return &ModExpr{ast: &ast}, nil
}

View file

@ -0,0 +1,22 @@
package modexpr
import "github.com/lmika/awstools/internal/dynamo-browse/models"
type ModExpr struct {
ast *astExpr
}
func (me *ModExpr) Patch(item models.Item) (models.Item, error) {
newItem := item.Clone()
for _, attribute := range me.ast.Attributes {
var err error
name := attribute.Name
newItem[name], err = attribute.dynamoValue()
if err != nil {
return nil, err
}
}
return newItem, nil
}

View file

@ -0,0 +1,39 @@
package modexpr_test
import (
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/awstools/internal/dynamo-browse/models"
"github.com/lmika/awstools/internal/dynamo-browse/models/modexpr"
"github.com/stretchr/testify/assert"
"testing"
)
func TestModExpr_Patch(t *testing.T) {
t.Run("patch with new attributes", func(t *testing.T) {
modExpr, err := modexpr.Parse(`alpha="new value", beta="another new value"`)
assert.NoError(t, err)
oldItem := models.Item{}
newItem, err := modExpr.Patch(oldItem)
assert.NoError(t, err)
assert.Equal(t, "new value", newItem["alpha"].(*types.AttributeValueMemberS).Value)
assert.Equal(t, "another new value", newItem["beta"].(*types.AttributeValueMemberS).Value)
})
t.Run("patch with existing attributes", func(t *testing.T) {
modExpr, err := modexpr.Parse(`alpha="new value", beta="another new value"`)
assert.NoError(t, err)
oldItem := models.Item{
"old": &types.AttributeValueMemberS{Value: "before"},
"beta": &types.AttributeValueMemberS{Value: "before beta"},
}
newItem, err := modExpr.Patch(oldItem)
assert.NoError(t, err)
assert.Equal(t, "before", newItem["old"].(*types.AttributeValueMemberS).Value)
assert.Equal(t, "new value", newItem["alpha"].(*types.AttributeValueMemberS).Value)
assert.Equal(t, "another new value", newItem["beta"].(*types.AttributeValueMemberS).Value)
})
}

View file

@ -13,6 +13,17 @@ type Provider struct {
client *dynamodb.Client
}
func (p *Provider) PutItem(ctx context.Context, name string, item models.Item) error {
_, err := p.client.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(name),
Item: item,
})
if err != nil {
return errors.Wrapf(err, "cannot execute put on table %v", name)
}
return nil
}
func NewProvider(client *dynamodb.Client) *Provider {
return &Provider{client: client}
}

View file

@ -9,4 +9,5 @@ import (
type TableProvider interface {
ScanItems(ctx context.Context, tableName string) ([]models.Item, error)
DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error
PutItem(ctx context.Context, name string, item models.Item) error
}

View file

@ -60,6 +60,10 @@ func (s *Service) Scan(ctx context.Context, table string) (*models.ResultSet, er
}, nil
}
func (s *Service) Put(ctx context.Context, tableName string, item models.Item) error {
return s.provider.PutItem(ctx, tableName, item)
}
func (s *Service) Delete(ctx context.Context, name string, item models.Item) error {
// TODO: do not hardcode keys
return s.provider.DeleteItem(ctx, name, map[string]types.AttributeValue{

View file

@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lmika/awstools/internal/common/ui/commandctrl"
"github.com/lmika/awstools/internal/common/ui/dispatcher"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
@ -19,13 +20,13 @@ import (
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
inactiveHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
)
type uiModel struct {
@ -34,20 +35,21 @@ type uiModel struct {
tableWidth, tableHeight int
ready bool
ready bool
//resultSet *models.ResultSet
state controllers.State
message string
state controllers.State
message string
pendingInput *events.PromptForInput
textInput textinput.Model
dispatcher *dispatcher.Dispatcher
commandController *commandctrl.CommandController
tableReadController *controllers.TableReadController
tableWriteController *controllers.TableWriteController
}
func NewModel(dispatcher *dispatcher.Dispatcher, tableReadController *controllers.TableReadController, tableWriteController *controllers.TableWriteController) tea.Model {
func NewModel(dispatcher *dispatcher.Dispatcher, commandController *commandctrl.CommandController, tableReadController *controllers.TableReadController, tableWriteController *controllers.TableWriteController) tea.Model {
tbl := table.New([]string{"pk", "sk"}, 100, 20)
rows := make([]table.Row, 0)
tbl.SetRows(rows)
@ -60,6 +62,7 @@ func NewModel(dispatcher *dispatcher.Dispatcher, tableReadController *controller
textInput: textInput,
dispatcher: dispatcher,
commandController: commandController,
tableReadController: tableReadController,
tableWriteController: tableWriteController,
}
@ -174,7 +177,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+c", "esc":
m.pendingInput = nil
case "enter":
m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
m.invokeOperation(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
m.pendingInput = nil
default:
m.textInput, textInputCommands = m.textInput.Update(msg)
@ -193,12 +196,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateViewportToSelectedMessage()
// TODO: these should be moved somewhere else
case ":":
m.invokeOperation(context.Background(), m.commandController.Prompt())
case "s":
m.invokeOperation(m.tableReadController.Scan())
m.invokeOperation(context.Background(), m.tableReadController.Scan())
case "D":
m.invokeOperation(m.tableWriteController.Delete())
case "w":
m.invokeOperation(m.tableWriteController.EnableReadWrite())
m.invokeOperation(context.Background(), m.tableWriteController.Delete())
}
default:
m.textInput, textInputCommands = m.textInput.Update(msg)
@ -213,13 +216,13 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs)
}
func (m uiModel) invokeOperation(op uimodels.Operation) {
func (m uiModel) invokeOperation(ctx context.Context, op uimodels.Operation) {
state := m.state
if selectedItem, ok := m.selectedItem(); ok {
state.SelectedItem = selectedItem.item
}
ctx := controllers.ContextWithState(context.Background(), state)
ctx = controllers.ContextWithState(ctx, state)
m.dispatcher.Start(ctx, op)
}

View file

@ -1,7 +1,9 @@
package ui
import (
"bytes"
"context"
"encoding/json"
table "github.com/calyptia/go-bubble-table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
@ -67,7 +69,13 @@ func (m uiModel) Init() tea.Cmd {
func (m *uiModel) updateViewportToSelectedMessage() {
if message, ok := m.selectedMessage(); ok {
m.viewport.SetContent(message.Data)
// TODO: not all messages are JSON
formattedJson := new(bytes.Buffer)
if err := json.Indent(formattedJson, []byte(message.Data), "", " "); err == nil {
m.viewport.SetContent(formattedJson.String())
} else {
m.viewport.SetContent(message.Data)
}
} else {
m.viewport.SetContent("(no message selected)")
}
@ -204,8 +212,8 @@ func (m uiModel) headerView() string {
}
func (m uiModel) splitterView() string {
title := activeHeaderStyle.Render("Message")
line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
title := inactiveHeaderStyle.Render("Message")
line := inactiveHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
}