From ee6011bc3e9f478e6171696011cd5ed32793f871 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 1 Apr 2022 09:53:43 +1100 Subject: [PATCH 01/26] Some small quality of life improvements --- internal/dynamo-browse/ui/model.go | 2 +- internal/dynamo-browse/ui/teamodels/statusandprompt/model.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index e28b8f7..98e950f 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -71,7 +71,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if idx := m.tableView.SelectedItemIndex(); idx >= 0 { return m, m.tableWriteController.ToggleMark(idx) } - case "s": + case "r": return m, m.tableReadController.Rescan() case "/": return m, m.tableReadController.Filter() diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index d9ee1dd..380896b 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -61,6 +61,8 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.textInput = newTextInput return s, cmd } + } else { + s.statusMessage = "" } } From 306640abdb938798bf706d16ea5a2497011282fc Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 5 Apr 2022 13:39:14 +1000 Subject: [PATCH 02/26] Added the clone command in SSM --- internal/common/ui/commandctrl/commandctrl.go | 8 ++++- internal/common/ui/events/commands.go | 6 ++++ .../ssm-browse/controllers/ssmcontroller.go | 34 +++++++++++++++---- internal/ssm-browse/models/models.go | 3 ++ .../ssm-browse/providers/awsssm/provider.go | 30 ++++++++++++++-- .../services/ssmparameters/iface.go | 1 + .../services/ssmparameters/service.go | 9 +++++ internal/ssm-browse/ui/model.go | 13 +++++++ internal/ssm-browse/ui/ssmlist/ssmlist.go | 8 +++++ 9 files changed, 102 insertions(+), 10 deletions(-) diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 4332023..a71352a 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -2,6 +2,8 @@ package commandctrl import ( tea "github.com/charmbracelet/bubbletea" + "github.com/pkg/errors" + "log" "strings" "github.com/lmika/awstools/internal/common/ui/events" @@ -35,15 +37,18 @@ func (c *CommandController) Prompt() tea.Cmd { } func (c *CommandController) Execute(commandInput string) tea.Cmd { + log.Println("Received input: ", commandInput) input := strings.TrimSpace(commandInput) if input == "" { return nil } tokens := shellwords.Split(input) + log.Println("Tokens: ", tokens) command := c.lookupCommand(tokens[0]) if command == nil { - return events.SetStatus("no such command: " + tokens[0]) + log.Println("No such command: ", tokens) + return events.SetError(errors.New("no such command: " + tokens[0])) } return command(tokens[1:]) @@ -51,6 +56,7 @@ func (c *CommandController) Execute(commandInput string) tea.Cmd { func (c *CommandController) lookupCommand(name string) Command { for ctx := c.commandList; ctx != nil; ctx = ctx.parent { + log.Printf("Looking in command list: %v", c.commandList) if cmd, ok := ctx.Commands[name]; ok { return cmd } diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index 6a679de..99f70a7 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -10,6 +10,12 @@ func Error(err error) tea.Msg { return ErrorMsg(err) } +func SetError(err error) tea.Cmd { + return func() tea.Msg { + return Error(err) + } +} + func SetStatus(msg string) tea.Cmd { return func() tea.Msg { return StatusMsg(msg) diff --git a/internal/ssm-browse/controllers/ssmcontroller.go b/internal/ssm-browse/controllers/ssmcontroller.go index 2ee83b6..4af7335 100644 --- a/internal/ssm-browse/controllers/ssmcontroller.go +++ b/internal/ssm-browse/controllers/ssmcontroller.go @@ -4,6 +4,7 @@ import ( "context" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/ssm-browse/models" "github.com/lmika/awstools/internal/ssm-browse/services/ssmparameters" "sync" ) @@ -12,15 +13,15 @@ type SSMController struct { service *ssmparameters.Service // state - mutex *sync.Mutex + mutex *sync.Mutex prefix string } func New(service *ssmparameters.Service) *SSMController { return &SSMController{ service: service, - prefix: "/", - mutex: new(sync.Mutex), + prefix: "/", + mutex: new(sync.Mutex), } } @@ -32,7 +33,7 @@ func (c *SSMController) Fetch() tea.Cmd { } return NewParameterListMsg{ - Prefix: c.prefix, + Prefix: c.prefix, Parameters: res, } } @@ -50,8 +51,29 @@ func (c *SSMController) ChangePrefix(newPrefix string) tea.Cmd { c.prefix = newPrefix return NewParameterListMsg{ - Prefix: c.prefix, + Prefix: c.prefix, Parameters: res, } } -} \ No newline at end of file +} + +func (c *SSMController) Clone(param models.SSMParameter) tea.Cmd { + return events.PromptForInput("New key: ", func(value string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := c.service.Clone(ctx, param, value); err != nil { + return events.Error(err) + } + + res, err := c.service.List(context.Background(), c.prefix) + if err != nil { + return events.Error(err) + } + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } + } + }) +} diff --git a/internal/ssm-browse/models/models.go b/internal/ssm-browse/models/models.go index 777e6c8..74a9b7d 100644 --- a/internal/ssm-browse/models/models.go +++ b/internal/ssm-browse/models/models.go @@ -1,10 +1,13 @@ package models +import "github.com/aws/aws-sdk-go-v2/service/ssm/types" + type SSMParameters struct { Items []SSMParameter } type SSMParameter struct { Name string + Type types.ParameterType Value string } diff --git a/internal/ssm-browse/providers/awsssm/provider.go b/internal/ssm-browse/providers/awsssm/provider.go index 07786de..c7e72bb 100644 --- a/internal/ssm-browse/providers/awsssm/provider.go +++ b/internal/ssm-browse/providers/awsssm/provider.go @@ -4,11 +4,14 @@ import ( "context" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/lmika/awstools/internal/ssm-browse/models" "github.com/pkg/errors" "log" ) +const defaultKMSKeyIDForSecureStrings = "alias/aws/ssm" + type Provider struct { client *ssm.Client } @@ -23,13 +26,14 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode log.Printf("new prefix: %v", prefix) pager := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{ - Path: aws.String(prefix), - Recursive: true, + Path: aws.String(prefix), + Recursive: true, WithDecryption: true, }) items := make([]models.SSMParameter, 0) - outer: for pager.HasMorePages() { +outer: + for pager.HasMorePages() { out, err := pager.NextPage(ctx) if err != nil { return nil, errors.Wrap(err, "cannot get parameters from path") @@ -38,6 +42,7 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode for _, p := range out.Parameters { items = append(items, models.SSMParameter{ Name: aws.ToString(p.Name), + Type: p.Type, Value: aws.ToString(p.Value), }) if len(items) >= maxCount { @@ -48,3 +53,22 @@ func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*mode return &models.SSMParameters{Items: items}, nil } + +func (p *Provider) Put(ctx context.Context, param models.SSMParameter, override bool) error { + in := &ssm.PutParameterInput{ + Name: aws.String(param.Name), + Type: param.Type, + Value: aws.String(param.Value), + Overwrite: override, + } + if param.Type == types.ParameterTypeSecureString { + in.KeyId = aws.String(defaultKMSKeyIDForSecureStrings) + } + + _, err := p.client.PutParameter(ctx, in) + if err != nil { + return errors.Wrap(err, "unable to put new SSM parameter") + } + + return nil +} \ No newline at end of file diff --git a/internal/ssm-browse/services/ssmparameters/iface.go b/internal/ssm-browse/services/ssmparameters/iface.go index cc23f54..406d733 100644 --- a/internal/ssm-browse/services/ssmparameters/iface.go +++ b/internal/ssm-browse/services/ssmparameters/iface.go @@ -7,4 +7,5 @@ import ( type SSMProvider interface { List(ctx context.Context, prefix string, maxCount int) (*models.SSMParameters, error) + Put(ctx context.Context, param models.SSMParameter, override bool) error } diff --git a/internal/ssm-browse/services/ssmparameters/service.go b/internal/ssm-browse/services/ssmparameters/service.go index 7706801..40f2042 100644 --- a/internal/ssm-browse/services/ssmparameters/service.go +++ b/internal/ssm-browse/services/ssmparameters/service.go @@ -17,4 +17,13 @@ func NewService(provider SSMProvider) *Service { func (s *Service) List(ctx context.Context, prefix string) (*models.SSMParameters, error) { return s.provider.List(ctx, prefix, 100) +} + +func (s *Service) Clone(ctx context.Context, param models.SSMParameter, newName string) error { + newParam := models.SSMParameter{ + Name: newName, + Type: param.Type, + Value: param.Value, + } + return s.provider.Put(ctx, newParam, false) } \ No newline at end of file diff --git a/internal/ssm-browse/ui/model.go b/internal/ssm-browse/ui/model.go index ae625e9..e01fa2d 100644 --- a/internal/ssm-browse/ui/model.go +++ b/internal/ssm-browse/ui/model.go @@ -3,11 +3,13 @@ package ui import ( tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/commandctrl" + "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/awstools/internal/ssm-browse/controllers" "github.com/lmika/awstools/internal/ssm-browse/ui/ssmdetails" "github.com/lmika/awstools/internal/ssm-browse/ui/ssmlist" + "github.com/pkg/errors" ) type Model struct { @@ -27,6 +29,17 @@ func NewModel(controller *controllers.SSMController, cmdController *commandctrl. layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails), "") + cmdController.AddCommands(&commandctrl.CommandContext{ + Commands: map[string]commandctrl.Command{ + "clone": func(args []string) tea.Cmd { + if currentParam := ssmList.CurrentParameter(); currentParam != nil { + return controller.Clone(*currentParam) + } + return events.SetError(errors.New("no parameter selected")) + }, + }, + }) + root := layout.FullScreen(statusAndPrompt) return Model{ diff --git a/internal/ssm-browse/ui/ssmlist/ssmlist.go b/internal/ssm-browse/ui/ssmlist/ssmlist.go index 9d90964..e304e66 100644 --- a/internal/ssm-browse/ui/ssmlist/ssmlist.go +++ b/internal/ssm-browse/ui/ssmlist/ssmlist.go @@ -85,6 +85,14 @@ func (m *Model) emitNewSelectedParameter() tea.Cmd { } } +func (m *Model) CurrentParameter() *models.SSMParameter { + if row, ok := m.table.SelectedRow().(itemTableRow); ok { + return &(row.item) + } + + return nil +} + func (m *Model) View() string { return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View()) } From 6df67ce93b821f3f45ba947afbf13368dd48b124 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 19 May 2022 09:55:15 +1000 Subject: [PATCH 03/26] Started working on proper controllers --- internal/dynamo-browse/controllers/iface.go | 13 +++ .../dynamo-browse/controllers/tableread.go | 5 +- .../controllers/tableread_test.go | 107 ++++++++++++++++++ .../controllers/tablewrite_test.go | 7 +- .../providers/dynamo/provider_test.go | 67 ++++++----- .../services/tables/service_test.go | 45 ++++---- .../slog-view/ui/fullviewlinedetails/model.go | 5 +- internal/ssm-browse/ui/ssmlist/tblmodel.go | 2 +- test/testdynamo/client.go | 65 ++++++----- 9 files changed, 225 insertions(+), 91 deletions(-) create mode 100644 internal/dynamo-browse/controllers/iface.go create mode 100644 internal/dynamo-browse/controllers/tableread_test.go diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go new file mode 100644 index 0000000..7c74234 --- /dev/null +++ b/internal/dynamo-browse/controllers/iface.go @@ -0,0 +1,13 @@ +package controllers + +import ( + "context" + "github.com/lmika/awstools/internal/dynamo-browse/models" +) + +type TableReadService interface { + ListTables(background context.Context) ([]string, error) + Describe(ctx context.Context, table string) (*models.TableInfo, error) + Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) + Filter(resultSet *models.ResultSet, filter string) *models.ResultSet +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index d185179..6f019be 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -5,13 +5,12 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/models" - "github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/pkg/errors" "sync" ) type TableReadController struct { - tableService *tables.Service + tableService TableReadService tableName string // state @@ -20,7 +19,7 @@ type TableReadController struct { filter string } -func NewTableReadController(tableService *tables.Service, tableName string) *TableReadController { +func NewTableReadController(tableService TableReadService, tableName string) *TableReadController { return &TableReadController{ tableService: tableService, tableName: tableName, diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go new file mode 100644 index 0000000..e0ef160 --- /dev/null +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -0,0 +1,107 @@ +package controllers_test + +import ( + "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" + "github.com/lmika/awstools/test/testdynamo" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestTableReadController_InitTable(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should prompt for table if no table name provided", func(t *testing.T) { + readController := controllers.NewTableReadController(service, "") + + cmd := readController.Init() + event := cmd() + + assert.IsType(t, controllers.PromptForTableMsg{}, event) + }) + + t.Run("should scan table if table name provided", func(t *testing.T) { + readController := controllers.NewTableReadController(service, "") + + cmd := readController.Init() + event := cmd() + + assert.IsType(t, controllers.PromptForTableMsg{}, event) + }) +} + +func TestTableReadController_ListTables(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + readController := controllers.NewTableReadController(service, "") + + t.Run("returns a list of tables", func(t *testing.T) { + cmd := readController.ListTables() + event := cmd().(controllers.PromptForTableMsg) + + assert.Equal(t, []string{"alpha-table", "bravo-table"}, event.Tables) + + selectedCmd := event.OnSelected("alpha-table") + selectedEvent := selectedCmd() + + resultSet := selectedEvent.(controllers.NewResultSet) + assert.Equal(t, "alpha-table", resultSet.ResultSet.TableInfo.Name) + assert.Equal(t, "pk", resultSet.ResultSet.TableInfo.Keys.PartitionKey) + assert.Equal(t, "sk", resultSet.ResultSet.TableInfo.Keys.SortKey) + }) +} + +var testData = []testdynamo.TestData{ + { + TableName: "alpha-table", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, + }, + { + TableName: "bravo-table", + Data: []map[string]interface{}{ + { + "pk": "foo", + "sk": "bar", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, + }, +} diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index ac0c49a..a8f5c1a 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -2,11 +2,6 @@ package controllers_test import ( "testing" - - "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" - "github.com/lmika/awstools/test/testdynamo" ) func TestTableWriteController_ToggleReadWrite(t *testing.T) { @@ -159,6 +154,7 @@ func TestTableWriteController_Delete(t *testing.T) { */ } +/* type controller struct { tableName string tableService *tables.Service @@ -197,3 +193,4 @@ var testData = testdynamo.TestData{ "gamma": "foobar", }, } +*/ diff --git a/internal/dynamo-browse/providers/dynamo/provider_test.go b/internal/dynamo-browse/providers/dynamo/provider_test.go index a408bc2..1540792 100644 --- a/internal/dynamo-browse/providers/dynamo/provider_test.go +++ b/internal/dynamo-browse/providers/dynamo/provider_test.go @@ -11,9 +11,9 @@ import ( ) func TestProvider_ScanItems(t *testing.T) { - tableName := "provider-scanimages-test-table" + tableName := "test-table" - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -24,9 +24,9 @@ func TestProvider_ScanItems(t *testing.T) { assert.NoError(t, err) assert.Len(t, items, 3) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2])) }) t.Run("should return error if table name does not exist", func(t *testing.T) { @@ -39,10 +39,10 @@ func TestProvider_ScanItems(t *testing.T) { } func TestProvider_DeleteItem(t *testing.T) { - tableName := "provider-deleteitem-test-table" + tableName := "test-table" t.Run("should delete item if exists in table", func(t *testing.T) { - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -57,14 +57,14 @@ func TestProvider_DeleteItem(t *testing.T) { assert.NoError(t, err) assert.Len(t, items, 2) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) - assert.NotContains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2])) + assert.NotContains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1])) }) t.Run("should do nothing if key does not exist", func(t *testing.T) { - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -79,13 +79,13 @@ func TestProvider_DeleteItem(t *testing.T) { assert.NoError(t, err) assert.Len(t, items, 3) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) - assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0].Data[2])) }) t.Run("should return error if table name does not exist", func(t *testing.T) { - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -97,22 +97,27 @@ func TestProvider_DeleteItem(t *testing.T) { }) } -var testData = testdynamo.TestData{ +var testData = []testdynamo.TestData{ { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", + TableName: "test-table", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, }, } diff --git a/internal/dynamo-browse/services/tables/service_test.go b/internal/dynamo-browse/services/tables/service_test.go index 9edc13c..3c559c7 100644 --- a/internal/dynamo-browse/services/tables/service_test.go +++ b/internal/dynamo-browse/services/tables/service_test.go @@ -11,9 +11,9 @@ import ( ) func TestService_Describe(t *testing.T) { - tableName := "service-describe-table" + tableName := "service-test-data" - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -33,9 +33,9 @@ func TestService_Describe(t *testing.T) { } func TestService_Scan(t *testing.T) { - tableName := "service-scan-test-table" + tableName := "service-test-data" - client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() provider := dynamo.NewProvider(client) @@ -58,22 +58,27 @@ func TestService_Scan(t *testing.T) { }) } -var testData = testdynamo.TestData{ +var testData = []testdynamo.TestData{ { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", + TableName: "service-test-data", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, }, } diff --git a/internal/slog-view/ui/fullviewlinedetails/model.go b/internal/slog-view/ui/fullviewlinedetails/model.go index 6b0aca5..778841e 100644 --- a/internal/slog-view/ui/fullviewlinedetails/model.go +++ b/internal/slog-view/ui/fullviewlinedetails/model.go @@ -8,7 +8,7 @@ import ( ) type Model struct { - submodel tea.Model + submodel tea.Model lineDetails *linedetails.Model visible bool @@ -16,7 +16,7 @@ type Model struct { func NewModel(submodel tea.Model) *Model { return &Model{ - submodel: submodel, + submodel: submodel, lineDetails: linedetails.New(), } } @@ -49,6 +49,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) ViewItem(item *models.LogLine) { m.visible = true m.lineDetails.SetSelectedItem(item) + m.lineDetails.SetFocused(true) } func (m *Model) View() string { diff --git a/internal/ssm-browse/ui/ssmlist/tblmodel.go b/internal/ssm-browse/ui/ssmlist/tblmodel.go index d7c0d7e..6a28598 100644 --- a/internal/ssm-browse/ui/ssmlist/tblmodel.go +++ b/internal/ssm-browse/ui/ssmlist/tblmodel.go @@ -14,7 +14,7 @@ type itemTableRow struct { func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { firstLine := strings.SplitN(mtr.item.Value, "\n", 2)[0] - line := fmt.Sprintf("%s\t%s\t%s", mtr.item.Name, "String", firstLine) + line := fmt.Sprintf("%s\t%s\t%s", mtr.item.Name, mtr.item.Type, firstLine) if index == model.Cursor() { fmt.Fprintln(w, model.Styles.SelectedRow.Render(line)) diff --git a/test/testdynamo/client.go b/test/testdynamo/client.go index bb7be11..af3842e 100644 --- a/test/testdynamo/client.go +++ b/test/testdynamo/client.go @@ -13,9 +13,12 @@ import ( "github.com/stretchr/testify/assert" ) -type TestData []map[string]interface{} +type TestData struct { + TableName string + Data []map[string]interface{} +} -func SetupTestTable(t *testing.T, tableName string, testData TestData) (*dynamodb.Client, func()) { +func SetupTestTable(t *testing.T, testData []TestData) (*dynamodb.Client, func()) { t.Helper() ctx := context.Background() @@ -27,37 +30,41 @@ func SetupTestTable(t *testing.T, tableName string, testData TestData) (*dynamod dynamoClient := dynamodb.NewFromConfig(cfg, dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) - _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{ - TableName: aws.String(tableName), - KeySchema: []types.KeySchemaElement{ - {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, - {AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange}, - }, - AttributeDefinitions: []types.AttributeDefinition{ - {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, - {AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS}, - }, - ProvisionedThroughput: &types.ProvisionedThroughput{ - ReadCapacityUnits: aws.Int64(100), - WriteCapacityUnits: aws.Int64(100), - }, - }) - assert.NoError(t, err) - - for _, item := range testData { - m, err := attributevalue.MarshalMap(item) - assert.NoError(t, err) - - _, err = dynamoClient.PutItem(ctx, &dynamodb.PutItemInput{ - TableName: aws.String(tableName), - Item: m, + for _, table := range testData { + _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String(table.TableName), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange}, + }, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS}, + }, + ProvisionedThroughput: &types.ProvisionedThroughput{ + ReadCapacityUnits: aws.Int64(100), + WriteCapacityUnits: aws.Int64(100), + }, }) assert.NoError(t, err) + + for _, item := range table.Data { + m, err := attributevalue.MarshalMap(item) + assert.NoError(t, err) + + _, err = dynamoClient.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(table.TableName), + Item: m, + }) + assert.NoError(t, err) + } } return dynamoClient, func() { - dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ - TableName: aws.String(tableName), - }) + for _, table := range testData { + dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ + TableName: aws.String(table.TableName), + }) + } } } From f6e38bbdeb24c19c3100c54e55ea7eb6affd0767 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 19 May 2022 10:48:47 +1000 Subject: [PATCH 04/26] Added an export command to dynamo-browse --- cmd/dynamo-browse/main.go | 2 +- docker-compose.yml | 2 +- .../dynamo-browse/controllers/tableread.go | 37 ++++++++++ .../controllers/tableread_test.go | 71 +++++++++++++++++++ internal/dynamo-browse/models/items.go | 4 +- internal/dynamo-browse/ui/model.go | 8 +++ test/testdynamo/client.go | 2 +- 7 files changed, 121 insertions(+), 5 deletions(-) diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 3427e2d..f72de9c 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -34,7 +34,7 @@ func main() { var dynamoClient *dynamodb.Client if *flagLocal { dynamoClient = dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:18000"))) } else { dynamoClient = dynamodb.NewFromConfig(cfg) } diff --git a/docker-compose.yml b/docker-compose.yml index ff5b618..eb9239e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,4 +3,4 @@ services: dynamo: image: amazon/dynamodb-local:latest ports: - - 8000:8000 \ No newline at end of file + - 18000:8000 \ No newline at end of file diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 6f019be..23b6d83 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -2,10 +2,12 @@ package controllers import ( "context" + "encoding/csv" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/pkg/errors" + "os" "sync" ) @@ -76,6 +78,41 @@ func (c *TableReadController) Rescan() tea.Cmd { } } +func (c *TableReadController) ExportCSV(filename string) tea.Cmd { + return func() tea.Msg { + resultSet := c.resultSet + if resultSet == nil { + return events.Error(errors.New("no result set")) + } + + f, err := os.Create(filename) + if err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + defer f.Close() + + cw := csv.NewWriter(f) + defer cw.Flush() + + columns := resultSet.Columns + if err := cw.Write(columns); err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + + row := make([]string, len(resultSet.Columns)) + for _, item := range resultSet.Items() { + for i, col := range columns { + row[i], _ = item.AttributeValueAsString(col) + } + if err := cw.Write(row); err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + } + + return nil + } +} + func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet) tea.Msg { newResultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo) if err != nil { diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index e0ef160..006a390 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -1,11 +1,16 @@ package controllers_test import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/events" "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" "github.com/lmika/awstools/test/testdynamo" "github.com/stretchr/testify/assert" + "os" + "strings" "testing" ) @@ -59,6 +64,72 @@ func TestTableReadController_ListTables(t *testing.T) { }) } +func TestTableReadController_ExportCSV(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + readController := controllers.NewTableReadController(service, "alpha-table") + + t.Run("should export result set to CSV file", func(t *testing.T) { + tempFile := tempFile(t) + + invokeCommand(t, readController.Init()) + invokeCommand(t, readController.ExportCSV(tempFile)) + + bts, err := os.ReadFile(tempFile) + assert.NoError(t, err) + + 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", + }, "")) + }) + + t.Run("should return error if result set is not set", func(t *testing.T) { + tempFile := tempFile(t) + readController := controllers.NewTableReadController(service, "non-existant-table") + + invokeCommandExpectingError(t, readController.Init()) + invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) + }) + + // Hidden items? +} + +func tempFile(t *testing.T) string { + t.Helper() + + tempFile, err := os.CreateTemp("", "export.csv") + assert.NoError(t, err) + tempFile.Close() + + t.Cleanup(func() { + os.Remove(tempFile.Name()) + }) + + return tempFile.Name() +} + +func invokeCommand(t *testing.T, cmd tea.Cmd) { + msg := cmd() + + err, isErr := msg.(events.ErrorMsg) + if isErr { + assert.Fail(t, fmt.Sprintf("expected no error but got one: %v", err)) + } +} + +func invokeCommandExpectingError(t *testing.T, cmd tea.Cmd) { + msg := cmd() + + _, isErr := msg.(events.ErrorMsg) + assert.True(t, isErr) +} + var testData = []testdynamo.TestData{ { TableName: "alpha-table", diff --git a/internal/dynamo-browse/models/items.go b/internal/dynamo-browse/models/items.go index 13a0b6a..49b16dd 100644 --- a/internal/dynamo-browse/models/items.go +++ b/internal/dynamo-browse/models/items.go @@ -25,6 +25,6 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue { return itemKey } -func (i Item) AttributeValueAsString(k string) (string, bool) { - return attributeToString(i[k]) +func (i Item) AttributeValueAsString(key string) (string, bool) { + return attributeToString(i[key]) } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 98e950f..d98b579 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -3,12 +3,14 @@ package ui import ( tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/commandctrl" + "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/tableselect" + "github.com/pkg/errors" ) type Model struct { @@ -38,6 +40,12 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon return rc.ScanTable(args[0]) } }, + "export": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected filename")) + } + return rc.ExportCSV(args[1]) + }, "unmark": commandctrl.NoArgCommand(rc.Unmark()), "delete": commandctrl.NoArgCommand(wc.DeleteMarked()), }, diff --git a/test/testdynamo/client.go b/test/testdynamo/client.go index af3842e..d50935c 100644 --- a/test/testdynamo/client.go +++ b/test/testdynamo/client.go @@ -28,7 +28,7 @@ func SetupTestTable(t *testing.T, testData []TestData) (*dynamodb.Client, func() assert.NoError(t, err) dynamoClient := dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:18000"))) for _, table := range testData { _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{ From 33e10299cf089ad32b6ca12f5f905e3f72633354 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 19 May 2022 10:51:07 +1000 Subject: [PATCH 05/26] Moved logging to a temp file --- internal/common/ui/logging/debug.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/common/ui/logging/debug.go b/internal/common/ui/logging/debug.go index 37decab..12ddea1 100644 --- a/internal/common/ui/logging/debug.go +++ b/internal/common/ui/logging/debug.go @@ -7,7 +7,14 @@ import ( ) func EnableLogging() (closeFn func()) { - f, err := tea.LogToFile("debug.log", "debug") + tempFile, err := os.CreateTemp("", "debug.log") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + tempFile.Close() + + f, err := tea.LogToFile(tempFile.Name(), "debug") if err != nil { fmt.Println("fatal:", err) os.Exit(1) From 3319a9d4aadb13f4750ab7326828cc7b2032af65 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 19 May 2022 10:58:56 +1000 Subject: [PATCH 06/26] Fixed a small bug with the export command --- internal/dynamo-browse/ui/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index d98b579..f59642b 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -44,7 +44,7 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon if len(args) == 0 { return events.SetError(errors.New("expected filename")) } - return rc.ExportCSV(args[1]) + return rc.ExportCSV(args[0]) }, "unmark": commandctrl.NoArgCommand(rc.Unmark()), "delete": commandctrl.NoArgCommand(wc.DeleteMarked()), From b0399e41ee73f6e60e7ec8aa88b1c4213653a993 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 May 2022 08:11:30 +1000 Subject: [PATCH 07/26] dialog-prompt: started working on dialog prompt control --- internal/dynamo-browse/ui/model.go | 7 +- .../ui/teamodels/dialogprompt/dialogmodel.go | 33 +++++++ .../ui/teamodels/dialogprompt/model.go | 38 +++++++++ .../ui/teamodels/layout/composit.go | 85 +++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 internal/dynamo-browse/ui/teamodels/dialogprompt/dialogmodel.go create mode 100644 internal/dynamo-browse/ui/teamodels/dialogprompt/model.go create mode 100644 internal/dynamo-browse/ui/teamodels/layout/composit.go diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index f59642b..496d719 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -5,6 +5,7 @@ import ( "github.com/lmika/awstools/internal/common/ui/commandctrl" "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/controllers" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dialogprompt" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" @@ -28,7 +29,8 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon dtv := dynamotableview.New() div := dynamoitemview.New() statusAndPrompt := statusandprompt.New(layout.NewVBox(layout.LastChildFixedAt(17), dtv, div), "") - tableSelect := tableselect.New(statusAndPrompt) + dialogPrompt := dialogprompt.New(statusAndPrompt) + tableSelect := tableselect.New(dialogPrompt) cc.AddCommands(&commandctrl.CommandContext{ Commands: map[string]commandctrl.Command{ @@ -51,7 +53,8 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon }, }) - root := layout.FullScreen(tableSelect) + //root := layout.FullScreen(tableSelect) + root := layout.FullScreen(dialogPrompt) return Model{ tableReadController: rc, diff --git a/internal/dynamo-browse/ui/teamodels/dialogprompt/dialogmodel.go b/internal/dynamo-browse/ui/teamodels/dialogprompt/dialogmodel.go new file mode 100644 index 0000000..326e9a9 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/dialogprompt/dialogmodel.go @@ -0,0 +1,33 @@ +package dialogprompt + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" +) + +var style = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("63")) + +type dialogModel struct { + w, h int + borderStyle lipgloss.Style +} + +func (d *dialogModel) Init() tea.Cmd { + return nil +} + +func (d *dialogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return d, nil +} + +func (d *dialogModel) View() string { + return style.Width(d.w).Height(d.h).Render("Hello this is a test of some content") +} + +func (d *dialogModel) Resize(w, h int) layout.ResizingModel { + d.w, d.h = w-2, h-2 + return d +} diff --git a/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go b/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go new file mode 100644 index 0000000..3f8c1fd --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/dialogprompt/model.go @@ -0,0 +1,38 @@ +package dialogprompt + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" +) + +type Model struct { + compositor *layout.Compositor +} + +func New(model layout.ResizingModel) *Model { + m := &Model{ + compositor: layout.NewCompositor(model), + } + // TEMP + m.compositor.SetOverlay(&dialogModel{}, 5, 5, 30, 12) + return m +} + +func (m *Model) Init() tea.Cmd { + return m.compositor.Init() +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + newModel, cmd := m.compositor.Update(msg) + m.compositor = newModel.(*layout.Compositor) + return m, cmd +} + +func (m *Model) View() string { + return m.compositor.View() +} + +func (m *Model) Resize(w, h int) layout.ResizingModel { + m.compositor = m.compositor.Resize(w, h).(*layout.Compositor) + return m +} diff --git a/internal/dynamo-browse/ui/teamodels/layout/composit.go b/internal/dynamo-browse/ui/teamodels/layout/composit.go new file mode 100644 index 0000000..055521f --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/layout/composit.go @@ -0,0 +1,85 @@ +package layout + +import ( + "bufio" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "strings" +) + +type Compositor struct { + background ResizingModel + + foreground ResizingModel + foreX, foreY int + foreW, foreH int +} + +func NewCompositor(background ResizingModel) *Compositor { + return &Compositor{ + background: background, + } +} + +func (c *Compositor) Init() tea.Cmd { + return c.background.Init() +} + +func (c *Compositor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // TODO: allow the compositor the + newM, cmd := c.background.Update(msg) + c.background = newM.(ResizingModel) + return c, cmd +} + +func (c *Compositor) SetOverlay(m ResizingModel, x, y, w, h int) { + c.foreground = m + c.foreX, c.foreY = x, y + c.foreW, c.foreH = w, h +} + +func (c *Compositor) View() string { + if c.foreground == nil { + return c.background.View() + } + + // Need to compose + backgroundView := c.background.View() + foregroundViewLines := strings.Split(c.foreground.View(), "\n") + + backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView)) + compositeOutput := new(strings.Builder) + + r := 0 + for backgroundScanner.Scan() { + if r > 0 { + compositeOutput.WriteRune('\n') + } + + line := backgroundScanner.Text() + if r >= c.foreY && r < c.foreY+c.foreH { + compositeOutput.WriteString(line[:c.foreX]) + + foregroundScanPos := r - c.foreY + if foregroundScanPos < len(foregroundViewLines) { + displayLine := foregroundViewLines[foregroundScanPos] + compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" "))) + } + + compositeOutput.WriteString(line[c.foreX+c.foreW:]) + } else { + compositeOutput.WriteString(line) + } + r++ + } + + return compositeOutput.String() +} + +func (c *Compositor) Resize(w, h int) ResizingModel { + c.background = c.background.Resize(w, h) + if c.foreground != nil { + c.foreground = c.foreground.Resize(c.foreW, c.foreH) + } + return c +} From 174bab36c34cd220ed43763629524c8f1f7e924c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 May 2022 09:01:39 +1000 Subject: [PATCH 08/26] put-items: started adding some basic commands for putting items --- cmd/dynamo-browse/main.go | 5 +- internal/dynamo-browse/controllers/state.go | 48 ++++++++---- .../dynamo-browse/controllers/tableread.go | 55 ++++++-------- .../controllers/tableread_test.go | 21 ++++-- .../dynamo-browse/controllers/tablewrite.go | 42 ++++++++++- .../controllers/tablewrite_test.go | 74 ++++++++++++++----- internal/dynamo-browse/models/models.go | 23 ++++++ internal/dynamo-browse/ui/model.go | 9 +++ .../ui/teamodels/dynamotableview/model.go | 1 + test/cmd/load-test-table/main.go | 4 +- 10 files changed, 203 insertions(+), 79 deletions(-) diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index f72de9c..f56a40b 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -43,8 +43,9 @@ func main() { tableService := tables.NewService(dynamoProvider) - tableReadController := controllers.NewTableReadController(tableService, *flagTable) - tableWriteController := controllers.NewTableWriteController(tableService, tableReadController) + state := controllers.NewState() + tableReadController := controllers.NewTableReadController(state, tableService, *flagTable) + tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController) commandController := commandctrl.NewCommandController() model := ui.NewModel(tableReadController, tableWriteController, commandController) diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index a711e6d..3518e6b 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -1,28 +1,46 @@ package controllers import ( - "context" + "sync" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type State struct { - ResultSet *models.ResultSet - SelectedItem models.Item - - // InReadWriteMode indicates whether modifications can be made to the table - InReadWriteMode bool + mutex *sync.Mutex + resultSet *models.ResultSet + filter string } -type stateContextKeyType struct{} - -var stateContextKey = stateContextKeyType{} - -func CurrentState(ctx context.Context) State { - state, _ := ctx.Value(stateContextKey).(State) - return state +func NewState() *State { + return &State{ + mutex: new(sync.Mutex), + } } -func ContextWithState(ctx context.Context, state State) context.Context { - return context.WithValue(ctx, stateContextKey, state) +func (s *State) ResultSet() *models.ResultSet { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.resultSet +} + +func (s *State) Filter() string { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.filter +} + +func (s *State) withResultSet(rs func(*models.ResultSet)) { + s.mutex.Lock() + defer s.mutex.Unlock() + + rs(s.resultSet) +} + +func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.resultSet = resultSet + s.filter = filter } diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 23b6d83..07bbea8 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -16,13 +16,15 @@ type TableReadController struct { tableName string // state - mutex *sync.Mutex - resultSet *models.ResultSet - filter string + mutex *sync.Mutex + state *State + //resultSet *models.ResultSet + //filter string } -func NewTableReadController(tableService TableReadService, tableName string) *TableReadController { +func NewTableReadController(state *State, tableService TableReadService, tableName string) *TableReadController { return &TableReadController{ + state: state, tableService: tableService, tableName: tableName, mutex: new(sync.Mutex), @@ -68,19 +70,19 @@ func (c *TableReadController) ScanTable(name string) tea.Cmd { return events.Error(err) } - return c.setResultSetAndFilter(resultSet, c.filter) + return c.setResultSetAndFilter(resultSet, c.state.Filter()) } } func (c *TableReadController) Rescan() tea.Cmd { return func() tea.Msg { - return c.doScan(context.Background(), c.resultSet) + return c.doScan(context.Background(), c.state.ResultSet()) } } func (c *TableReadController) ExportCSV(filename string) tea.Cmd { return func() tea.Msg { - resultSet := c.resultSet + resultSet := c.state.ResultSet() if resultSet == nil { return events.Error(errors.New("no result set")) } @@ -119,39 +121,30 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu return events.Error(err) } - newResultSet = c.tableService.Filter(newResultSet, c.filter) + newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) - return c.setResultSetAndFilter(newResultSet, c.filter) + return c.setResultSetAndFilter(newResultSet, c.state.Filter()) } -func (c *TableReadController) ResultSet() *models.ResultSet { - c.mutex.Lock() - defer c.mutex.Unlock() - - return c.resultSet -} +//func (c *TableReadController) ResultSet() *models.ResultSet { +// c.mutex.Lock() +// defer c.mutex.Unlock() +// +// return c.resultSet +//} func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { - c.mutex.Lock() - defer c.mutex.Unlock() - - c.resultSet = resultSet - c.filter = filter + c.state.setResultSetAndFilter(resultSet, filter) return NewResultSet{resultSet} } func (c *TableReadController) Unmark() tea.Cmd { return func() tea.Msg { - resultSet := c.ResultSet() - - for i := range resultSet.Items() { - resultSet.SetMark(i, false) - } - - c.mutex.Lock() - defer c.mutex.Unlock() - - c.resultSet = resultSet + c.state.withResultSet(func(resultSet *models.ResultSet) { + for i := range resultSet.Items() { + resultSet.SetMark(i, false) + } + }) return ResultSetUpdated{} } } @@ -162,7 +155,7 @@ func (c *TableReadController) Filter() tea.Cmd { Prompt: "filter: ", OnDone: func(value string) tea.Cmd { return func() tea.Msg { - resultSet := c.ResultSet() + resultSet := c.state.ResultSet() newResultSet := c.tableService.Filter(resultSet, value) return c.setResultSetAndFilter(newResultSet, value) diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index 006a390..d061394 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -22,7 +22,7 @@ func TestTableReadController_InitTable(t *testing.T) { service := tables.NewService(provider) t.Run("should prompt for table if no table name provided", func(t *testing.T) { - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") cmd := readController.Init() event := cmd() @@ -31,7 +31,7 @@ func TestTableReadController_InitTable(t *testing.T) { }) t.Run("should scan table if table name provided", func(t *testing.T) { - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") cmd := readController.Init() event := cmd() @@ -46,7 +46,7 @@ func TestTableReadController_ListTables(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") t.Run("returns a list of tables", func(t *testing.T) { cmd := readController.ListTables() @@ -70,7 +70,7 @@ func TestTableReadController_ExportCSV(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(service, "alpha-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "alpha-table") t.Run("should export result set to CSV file", func(t *testing.T) { tempFile := tempFile(t) @@ -91,7 +91,7 @@ func TestTableReadController_ExportCSV(t *testing.T) { t.Run("should return error if result set is not set", func(t *testing.T) { tempFile := tempFile(t) - readController := controllers.NewTableReadController(service, "non-existant-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table") invokeCommandExpectingError(t, readController.Init()) invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) @@ -123,6 +123,17 @@ func invokeCommand(t *testing.T, cmd tea.Cmd) { } } +func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) { + msg := cmd() + + pi, isPi := msg.(events.PromptForInputMsg) + if !isPi { + assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one")) + } + + invokeCommand(t, pi.OnDone(promptValue)) +} + func invokeCommandExpectingError(t *testing.T, cmd tea.Cmd) { msg := cmd() diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 4b56b5f..a7276e1 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -3,18 +3,22 @@ package controllers import ( "context" "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" ) type TableWriteController struct { + state *State tableService *tables.Service tableReadControllers *TableReadController } -func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController { +func NewTableWriteController(state *State, tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController { return &TableWriteController{ + state: state, tableService: tableService, tableReadControllers: tableReadControllers, } @@ -22,16 +26,46 @@ func NewTableWriteController(tableService *tables.Service, tableReadControllers func (twc *TableWriteController) ToggleMark(idx int) tea.Cmd { return func() tea.Msg { - resultSet := twc.tableReadControllers.ResultSet() - resultSet.SetMark(idx, !resultSet.Marked(idx)) + twc.state.withResultSet(func(resultSet *models.ResultSet) { + resultSet.SetMark(idx, !resultSet.Marked(idx)) + }) return ResultSetUpdated{} } } +func (twc *TableWriteController) NewItem() tea.Cmd { + return func() tea.Msg { + twc.state.withResultSet(func(set *models.ResultSet) { + set.AddNewItem(models.Item{}, models.ItemAttribute{ + New: true, + Dirty: true, + }) + }) + return NewResultSet{twc.state.ResultSet()} + } +} + +func (twc *TableWriteController) SetItemValue(idx int, key string) tea.Cmd { + return func() tea.Msg { + 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} + set.SetDirty(idx, true) + }) + return ResultSetUpdated{} + } + }, + } + } +} + func (twc *TableWriteController) DeleteMarked() tea.Cmd { return func() tea.Msg { - resultSet := twc.tableReadControllers.ResultSet() + resultSet := twc.state.ResultSet() markedItems := resultSet.MarkedItems() if len(markedItems) == 0 { diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index a8f5c1a..745a869 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -1,6 +1,11 @@ package controllers_test import ( + "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" + "github.com/lmika/awstools/test/testdynamo" + "github.com/stretchr/testify/assert" "testing" ) @@ -173,24 +178,53 @@ func setupController(t *testing.T) (*controllers.TableWriteController, controlle tableService: tableService, }, cleanupFn } - -var testData = testdynamo.TestData{ - { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", - }, -} */ + +func TestTableWriteController_NewItem(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + 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) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + assert.Len(t, state.ResultSet().Items(), 3) + + invokeCommand(t, writeController.NewItem()) + newResultSet := state.ResultSet() + assert.Len(t, newResultSet.Items(), 4) + assert.Len(t, newResultSet.Items()[3], 0) + assert.True(t, newResultSet.IsNew(3)) + assert.True(t, newResultSet.IsDirty(3)) + }) +} + +func TestTableWriteController_SetItemValue(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + 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) { + 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("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetItemValue(0, "alpha"), "a new value") + + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "a new value", after) + assert.True(t, state.ResultSet().IsDirty(0)) + }) +} diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index e709e1b..40e4f31 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -10,6 +10,8 @@ type ResultSet struct { type ItemAttribute struct { Marked bool Hidden bool + Dirty bool + New bool } func (rs *ResultSet) Items() []Item { @@ -21,6 +23,11 @@ func (rs *ResultSet) SetItems(items []Item) { rs.attributes = make([]ItemAttribute, len(items)) } +func (rs *ResultSet) AddNewItem(item Item, attrs ItemAttribute) { + rs.items = append(rs.items, item) + rs.attributes = append(rs.attributes, attrs) +} + func (rs *ResultSet) SetMark(idx int, marked bool) { rs.attributes[idx].Marked = marked } @@ -29,6 +36,14 @@ func (rs *ResultSet) SetHidden(idx int, hidden bool) { rs.attributes[idx].Hidden = hidden } +func (rs *ResultSet) SetDirty(idx int, dirty bool) { + rs.attributes[idx].Dirty = dirty +} + +func (rs *ResultSet) SetNew(idx int, isNew bool) { + rs.attributes[idx].New = isNew +} + func (rs *ResultSet) Marked(idx int) bool { return rs.attributes[idx].Marked } @@ -37,6 +52,14 @@ func (rs *ResultSet) Hidden(idx int) bool { return rs.attributes[idx].Hidden } +func (rs *ResultSet) IsDirty(idx int) bool { + return rs.attributes[idx].Dirty +} + +func (rs *ResultSet) IsNew(idx int) bool { + return rs.attributes[idx].New +} + func (rs *ResultSet) MarkedItems() []Item { items := make([]Item, 0) for i, itemAttr := range rs.attributes { diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index f59642b..28ff05b 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -48,6 +48,15 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon }, "unmark": commandctrl.NoArgCommand(rc.Unmark()), "delete": commandctrl.NoArgCommand(wc.DeleteMarked()), + + // TEMP + "new-item": commandctrl.NoArgCommand(wc.NewItem()), + "set": func(args []string) tea.Cmd { + if len(args) != 1 { + return events.SetError(errors.New("expected attribute key")) + } + return wc.SetItemValue(dtv.SelectedItemIndex(), args[0]) + }, }, }) diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 2bf3f6f..c12bd7a 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -135,5 +135,6 @@ func (m *Model) postSelectedItemChanged() tea.Msg { } func (m *Model) Refresh() { + m.table.SetRows(m.rows) } diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index 1db13d7..df59068 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -19,7 +19,7 @@ import ( func main() { ctx := context.Background() tableName := "awstools-test" - totalItems := 300 + totalItems := 10 cfg, err := config.LoadDefaultConfig(ctx) if err != nil { @@ -27,7 +27,7 @@ func main() { } dynamoClient := dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:18000"))) if _, err = dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ TableName: aws.String(tableName), From 16cb6bdc6b75826cc0e74078e0fd3baf5d5725fa Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 May 2022 10:17:21 +1000 Subject: [PATCH 09/26] put-items: added a command to put a dirty item --- .../dynamo-browse/controllers/tablewrite.go | 28 +++++- .../controllers/tablewrite_test.go | 91 ++++++++++++++++++- .../dynamo-browse/services/tables/service.go | 11 +++ internal/dynamo-browse/ui/model.go | 7 +- .../ui/teamodels/dynamotableview/tblmodel.go | 29 ++++-- 5 files changed, 152 insertions(+), 14 deletions(-) diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index a7276e1..8099779 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -8,6 +8,7 @@ import ( "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/pkg/errors" ) type TableWriteController struct { @@ -46,7 +47,7 @@ func (twc *TableWriteController) NewItem() tea.Cmd { } } -func (twc *TableWriteController) SetItemValue(idx int, key string) tea.Cmd { +func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd { return func() tea.Msg { return events.PromptForInputMsg{ Prompt: "string value: ", @@ -63,6 +64,31 @@ func (twc *TableWriteController) SetItemValue(idx int, key string) tea.Cmd { } } +func (twc *TableWriteController) PutItem(idx int) tea.Cmd { + return func() tea.Msg { + resultSet := twc.state.ResultSet() + if !resultSet.IsDirty(idx) { + return events.Error(errors.New("item is not dirty")) + } + + return events.PromptForInputMsg{ + Prompt: "put item? ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + if value != "y" { + return nil + } + + if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil { + return events.Error(err) + } + return ResultSetUpdated{} + } + }, + } + } +} + func (twc *TableWriteController) DeleteMarked() tea.Cmd { return func() tea.Msg { resultSet := twc.state.ResultSet() diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 745a869..589f628 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -204,7 +204,7 @@ func TestTableWriteController_NewItem(t *testing.T) { }) } -func TestTableWriteController_SetItemValue(t *testing.T) { +func TestTableWriteController_SetStringValue(t *testing.T) { client, cleanupFn := testdynamo.SetupTestTable(t, testData) defer cleanupFn() @@ -221,10 +221,97 @@ func TestTableWriteController_SetItemValue(t *testing.T) { assert.Equal(t, "This is some value", before) assert.False(t, state.ResultSet().IsDirty(0)) - invokeCommandWithPrompt(t, writeController.SetItemValue(0, "alpha"), "a new value") + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") assert.Equal(t, "a new value", after) assert.True(t, state.ResultSet().IsDirty(0)) }) + + t.Run("should prevent duplicate partition,sort keys", func(t *testing.T) { + t.Skip("TODO") + }) +} + +func TestTableWriteController_PutItem(t *testing.T) { + t.Run("should put the selected item if dirty", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandWithPrompt(t, writeController.PutItem(0), "y") + + // Rescan the table + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "a new value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if user does not confirm", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item but do not put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandWithPrompt(t, writeController.PutItem(0), "n") + + current, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "a new value", current) + assert.True(t, state.ResultSet().IsDirty(0)) + + // Rescan the table to confirm item is not modified + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if not dirty", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandExpectingError(t, writeController.PutItem(0)) + }) + } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index 12ee69b..8e3a54d 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -81,6 +81,17 @@ func (s *Service) Put(ctx context.Context, tableInfo *models.TableInfo, item mod return s.provider.PutItem(ctx, tableInfo.Name, item) } +func (s *Service) PutItemAt(ctx context.Context, resultSet *models.ResultSet, index int) error { + item := resultSet.Items()[index] + if err := s.provider.PutItem(ctx, resultSet.TableInfo.Name, item); err != nil { + return err + } + + resultSet.SetDirty(index, false) + resultSet.SetNew(index, false) + return nil +} + func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items []models.Item) error { for _, item := range items { if err := s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)); err != nil { diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 28ff05b..c048d15 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -51,11 +51,14 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon // TEMP "new-item": commandctrl.NoArgCommand(wc.NewItem()), - "set": func(args []string) tea.Cmd { + "set-string": func(args []string) tea.Cmd { if len(args) != 1 { return events.SetError(errors.New("expected attribute key")) } - return wc.SetItemValue(dtv.SelectedItemIndex(), args[0]) + return wc.SetStringValue(dtv.SelectedItemIndex(), args[0]) + }, + "put": func(args []string) tea.Cmd { + return wc.PutItem(dtv.SelectedItemIndex()) }, }, }) diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index 5e5a08d..65e8dd3 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -14,6 +14,10 @@ import ( var ( markedRowStyle = lipgloss.NewStyle(). Background(lipgloss.Color("#e1e1e1")) + dirtyRowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e13131")) + newRowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#31e131")) ) type itemTableRow struct { @@ -24,6 +28,8 @@ type itemTableRow struct { func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { isMarked := mtr.resultSet.Marked(mtr.itemIndex) + isDirty := mtr.resultSet.IsDirty(mtr.itemIndex) + isNew := mtr.resultSet.IsNew(mtr.itemIndex) sb := strings.Builder{} for i, colName := range mtr.resultSet.Columns { @@ -42,15 +48,20 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { sb.WriteString("(other)") } } + + var style lipgloss.Style + if index == model.Cursor() { - style := model.Styles.SelectedRow - if isMarked { - style = style.Copy().Inherit(markedRowStyle) - } - fmt.Fprintln(w, style.Render(sb.String())) - } else if isMarked { - fmt.Fprintln(w, markedRowStyle.Render(sb.String())) - } else { - fmt.Fprintln(w, sb.String()) + style = model.Styles.SelectedRow } + if isMarked { + style = style.Copy().Inherit(markedRowStyle) + } + if isNew { + style = style.Copy().Inherit(newRowStyle) + } else if isDirty { + style = style.Copy().Inherit(dirtyRowStyle) + } + + fmt.Fprintln(w, style.Render(sb.String())) } From 33783ee6881f633d62f69df841459583da6c7dd9 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 May 2022 11:00:40 +1000 Subject: [PATCH 10/26] put-items: added noisy-touch This will delete the item before putting it back --- .../dynamo-browse/controllers/tablewrite.go | 58 ++++++++++ .../controllers/tablewrite_test.go | 103 +++++++++++++++++- internal/dynamo-browse/ui/model.go | 6 + 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 8099779..ca6abd6 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -89,6 +89,64 @@ func (twc *TableWriteController) PutItem(idx int) tea.Cmd { } } +func (twc *TableWriteController) TouchItem(idx int) tea.Cmd { + return func() tea.Msg { + resultSet := twc.state.ResultSet() + if resultSet.IsDirty(idx) { + return events.Error(errors.New("cannot touch dirty items")) + } + + return events.PromptForInputMsg{ + Prompt: "touch item? ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + if value != "y" { + return nil + } + + if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil { + return events.Error(err) + } + return ResultSetUpdated{} + } + }, + } + } +} + +func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd { + return func() tea.Msg { + resultSet := twc.state.ResultSet() + if resultSet.IsDirty(idx) { + return events.Error(errors.New("cannot noisy touch dirty items")) + } + + return events.PromptForInputMsg{ + Prompt: "noisy touch item? ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + if value != "y" { + return nil + } + + item := resultSet.Items()[0] + if err := twc.tableService.Delete(ctx, resultSet.TableInfo, []models.Item{item}); err != nil { + return events.Error(err) + } + + if err := twc.tableService.Put(ctx, resultSet.TableInfo, item); err != nil { + return events.Error(err) + } + + return twc.tableReadControllers.doScan(ctx, resultSet) + } + }, + } + } +} + func (twc *TableWriteController) DeleteMarked() tea.Cmd { return func() tea.Msg { resultSet := twc.state.ResultSet() diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 589f628..3eea9a5 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -313,5 +313,106 @@ func TestTableWriteController_PutItem(t *testing.T) { invokeCommandExpectingError(t, writeController.PutItem(0)) }) - +} + +func TestTableWriteController_TouchItem(t *testing.T) { + t.Run("should put the selected item if unmodified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.TouchItem(0), "y") + + // Rescan the table + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if modified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandExpectingError(t, writeController.TouchItem(0)) + }) +} + +func TestTableWriteController_NoisyTouchItem(t *testing.T) { + t.Run("should delete and put the selected item if unmodified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.NoisyTouchItem(0), "y") + + // Rescan the table + invokeCommand(t, readController.Rescan()) + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", after) + assert.False(t, state.ResultSet().IsDirty(0)) + }) + + t.Run("should not put the selected item if modified", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + // Read the table + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + // Modify the item and put it + invokeCommandWithPrompt(t, writeController.SetStringValue(0, "alpha"), "a new value") + invokeCommandExpectingError(t, writeController.NoisyTouchItem(0)) + }) } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index c048d15..e5fb64f 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -60,6 +60,12 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon "put": func(args []string) tea.Cmd { return wc.PutItem(dtv.SelectedItemIndex()) }, + "touch": func(args []string) tea.Cmd { + return wc.TouchItem(dtv.SelectedItemIndex()) + }, + "noisy-touch": func(args []string) tea.Cmd { + return wc.NoisyTouchItem(dtv.SelectedItemIndex()) + }, }, }) From 9204947d5eade61411b88756af6f76617ebd75ac Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 2 Jun 2022 21:43:14 +1000 Subject: [PATCH 11/26] Added renderers for the other types --- .../dynamo-browse/models/itemrender/coll.go | 52 ++++++++++++ .../models/itemrender/itemdisp.go | 49 +++++++++++ .../dynamo-browse/models/itemrender/nils.go | 15 ++++ .../models/itemrender/scalars.go | 82 +++++++++++++++++++ .../dynamo-browse/models/itemrender/sets.go | 51 ++++++++++++ .../dynamo-browse/models/itemrender/utils.go | 10 +++ internal/dynamo-browse/models/items.go | 9 +- .../ui/teamodels/dynamoitemview/model.go | 23 +++--- .../ui/teamodels/dynamotableview/tblmodel.go | 18 ++-- test/cmd/load-test-table/main.go | 24 ++++-- 10 files changed, 302 insertions(+), 31 deletions(-) create mode 100644 internal/dynamo-browse/models/itemrender/coll.go create mode 100644 internal/dynamo-browse/models/itemrender/itemdisp.go create mode 100644 internal/dynamo-browse/models/itemrender/nils.go create mode 100644 internal/dynamo-browse/models/itemrender/scalars.go create mode 100644 internal/dynamo-browse/models/itemrender/sets.go create mode 100644 internal/dynamo-browse/models/itemrender/utils.go diff --git a/internal/dynamo-browse/models/itemrender/coll.go b/internal/dynamo-browse/models/itemrender/coll.go new file mode 100644 index 0000000..2277e5b --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/coll.go @@ -0,0 +1,52 @@ +package itemrender + +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "sort" +) + +type ListRenderer types.AttributeValueMemberL + +func (sr *ListRenderer) TypeName() string { + return "L" +} + +func (sr *ListRenderer) StringValue() string { + if len(sr.Value) == 1 { + return fmt.Sprintf("(1 item)") + } + return fmt.Sprintf("(%d items)", len(sr.Value)) +} + +func (sr *ListRenderer) SubItems() []SubItem { + subitems := make([]SubItem, len(sr.Value)) + for i, r := range sr.Value { + subitems[i] = SubItem{Key: fmt.Sprint(i), Value: ToRenderer(r)} + } + return subitems +} + +type MapRenderer types.AttributeValueMemberM + +func (sr *MapRenderer) TypeName() string { + return "M" +} + +func (sr *MapRenderer) StringValue() string { + if len(sr.Value) == 1 { + return fmt.Sprintf("(1 item)") + } + return fmt.Sprintf("(%d items)", len(sr.Value)) +} + +func (sr *MapRenderer) SubItems() []SubItem { + subitems := make([]SubItem, 0) + for k, r := range sr.Value { + subitems = append(subitems, SubItem{Key: k, Value: ToRenderer(r)}) + } + sort.Slice(subitems, func(i, j int) bool { + return subitems[i].Key < subitems[j].Key + }) + return subitems +} diff --git a/internal/dynamo-browse/models/itemrender/itemdisp.go b/internal/dynamo-browse/models/itemrender/itemdisp.go new file mode 100644 index 0000000..47043c5 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/itemdisp.go @@ -0,0 +1,49 @@ +package itemrender + +import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + +type Renderer interface { + TypeName() string + StringValue() string + SubItems() []SubItem +} + +func ToRenderer(v types.AttributeValue) Renderer { + switch colVal := v.(type) { + case nil: + return nil + case *types.AttributeValueMemberS: + x := StringRenderer(*colVal) + return &x + case *types.AttributeValueMemberN: + x := NumberRenderer(*colVal) + return &x + case *types.AttributeValueMemberBOOL: + x := BoolRenderer(*colVal) + return &x + case *types.AttributeValueMemberNULL: + x := NullRenderer(*colVal) + return &x + case *types.AttributeValueMemberB: + x := BinaryRenderer(*colVal) + return &x + case *types.AttributeValueMemberL: + x := ListRenderer(*colVal) + return &x + case *types.AttributeValueMemberM: + x := MapRenderer(*colVal) + return &x + case *types.AttributeValueMemberBS: + return newBinarySetRenderer(colVal) + case *types.AttributeValueMemberNS: + return newNumberSetRenderer(colVal) + case *types.AttributeValueMemberSS: + return newStringSetRenderer(colVal) + } + return OtherRenderer{} +} + +type SubItem struct { + Key string + Value Renderer +} diff --git a/internal/dynamo-browse/models/itemrender/nils.go b/internal/dynamo-browse/models/itemrender/nils.go new file mode 100644 index 0000000..27a4388 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/nils.go @@ -0,0 +1,15 @@ +package itemrender + +type OtherRenderer struct{} + +func (u OtherRenderer) TypeName() string { + return "(other)" +} + +func (u OtherRenderer) StringValue() string { + return "(other)" +} + +func (u OtherRenderer) SubItems() []SubItem { + return nil +} diff --git a/internal/dynamo-browse/models/itemrender/scalars.go b/internal/dynamo-browse/models/itemrender/scalars.go new file mode 100644 index 0000000..f9c9b10 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/scalars.go @@ -0,0 +1,82 @@ +package itemrender + +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +type StringRenderer types.AttributeValueMemberS + +func (sr *StringRenderer) TypeName() string { + return "S" +} + +func (sr *StringRenderer) StringValue() string { + return sr.Value +} + +func (sr *StringRenderer) SubItems() []SubItem { + return nil +} + +type NumberRenderer types.AttributeValueMemberN + +func (sr *NumberRenderer) TypeName() string { + return "N" +} + +func (sr *NumberRenderer) StringValue() string { + return sr.Value +} + +func (sr *NumberRenderer) SubItems() []SubItem { + return nil +} + +type BoolRenderer types.AttributeValueMemberBOOL + +func (sr *BoolRenderer) TypeName() string { + return "BOOL" +} + +func (sr *BoolRenderer) StringValue() string { + if sr.Value { + return "True" + } + return "False" +} + +func (sr *BoolRenderer) SubItems() []SubItem { + return nil +} + +type BinaryRenderer types.AttributeValueMemberB + +func (sr *BinaryRenderer) TypeName() string { + return "B" +} + +func (sr *BinaryRenderer) StringValue() string { + if len(sr.Value) == 1 { + return fmt.Sprintf("(1 byte)") + } + return fmt.Sprintf("(%d bytes)", len(sr.Value)) +} + +func (sr *BinaryRenderer) SubItems() []SubItem { + return nil +} + +type NullRenderer types.AttributeValueMemberNULL + +func (sr *NullRenderer) TypeName() string { + return "NULL" +} + +func (sr *NullRenderer) StringValue() string { + return "null" +} + +func (sr *NullRenderer) SubItems() []SubItem { + return nil +} diff --git a/internal/dynamo-browse/models/itemrender/sets.go b/internal/dynamo-browse/models/itemrender/sets.go new file mode 100644 index 0000000..4885a32 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/sets.go @@ -0,0 +1,51 @@ +package itemrender + +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +type GenericRenderer struct { + typeName string + subitemValue []Renderer +} + +func (sr *GenericRenderer) TypeName() string { + return sr.typeName +} + +func (sr *GenericRenderer) StringValue() string { + return cardinality(len(sr.subitemValue), "item", "items") +} + +func (sr *GenericRenderer) SubItems() []SubItem { + subitems := make([]SubItem, len(sr.subitemValue)) + for i, r := range sr.subitemValue { + subitems[i] = SubItem{Key: fmt.Sprint(i), Value: r} + } + return subitems +} + +func newBinarySetRenderer(v *types.AttributeValueMemberBS) *GenericRenderer { + vs := make([]Renderer, len(v.Value)) + for i, b := range v.Value { + vs[i] = &BinaryRenderer{Value: b} + } + return &GenericRenderer{typeName: "BS", subitemValue: vs} +} + +func newNumberSetRenderer(v *types.AttributeValueMemberNS) *GenericRenderer { + vs := make([]Renderer, len(v.Value)) + for i, n := range v.Value { + vs[i] = &NumberRenderer{Value: n} + } + return &GenericRenderer{typeName: "NS", subitemValue: vs} +} + +func newStringSetRenderer(v *types.AttributeValueMemberSS) *GenericRenderer { + vs := make([]Renderer, len(v.Value)) + for i, s := range v.Value { + vs[i] = &StringRenderer{Value: s} + } + return &GenericRenderer{typeName: "SS", subitemValue: vs} +} diff --git a/internal/dynamo-browse/models/itemrender/utils.go b/internal/dynamo-browse/models/itemrender/utils.go new file mode 100644 index 0000000..061a636 --- /dev/null +++ b/internal/dynamo-browse/models/itemrender/utils.go @@ -0,0 +1,10 @@ +package itemrender + +import "fmt" + +func cardinality(c int, single, multi string) string { + if c == 1 { + return fmt.Sprintf("(%d %v)", c, single) + } + return fmt.Sprintf("(%d %v)", c, multi) +} diff --git a/internal/dynamo-browse/models/items.go b/internal/dynamo-browse/models/items.go index 49b16dd..d49d0d6 100644 --- a/internal/dynamo-browse/models/items.go +++ b/internal/dynamo-browse/models/items.go @@ -1,6 +1,9 @@ package models -import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" +) type Item map[string]types.AttributeValue @@ -28,3 +31,7 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue { func (i Item) AttributeValueAsString(key string) (string, bool) { return attributeToString(i[key]) } + +func (i Item) Renderer(key string) itemrender.Renderer { + return itemrender.ToRenderer(i[key]) +} diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index 9d874dc..4109f7b 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -2,10 +2,11 @@ package dynamoitemview import ( "fmt" + "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" + "io" "strings" "text/tabwriter" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -83,15 +84,8 @@ func (m *Model) updateViewportToSelectedMessage() { viewportContent := &strings.Builder{} tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0) for _, colName := range m.currentResultSet.Columns { - switch colVal := m.selectedItem[colName].(type) { - case nil: - break - case *types.AttributeValueMemberS: - fmt.Fprintf(tabWriter, "%v\tS\t%s\n", colName, colVal.Value) - case *types.AttributeValueMemberN: - fmt.Fprintf(tabWriter, "%v\tN\t%s\n", colName, colVal.Value) - default: - fmt.Fprintf(tabWriter, "%v\t?\t%s\n", colName, "(other)") + if r := m.selectedItem.Renderer(colName); r != nil { + m.renderItem(tabWriter, "", colName, r) } } @@ -100,3 +94,12 @@ func (m *Model) updateViewportToSelectedMessage() { m.viewport.Height = m.h - m.frameTitle.HeaderHeight() m.viewport.SetContent(viewportContent.String()) } + +func (m *Model) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer) { + fmt.Fprintf(w, "%s%v\t%s\t%s\n", prefix, name, r.TypeName(), r.StringValue()) + if subitems := r.SubItems(); len(subitems) > 0 { + for _, si := range subitems { + m.renderItem(w, prefix+" ", si.Key, si.Value) + } + } +} diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index 65e8dd3..e3a726e 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -6,18 +6,17 @@ import ( "io" "strings" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" table "github.com/calyptia/go-bubble-table" "github.com/lmika/awstools/internal/dynamo-browse/models" ) var ( markedRowStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#e1e1e1")) + Background(lipgloss.Color("#e1e1e1")) dirtyRowStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#e13131")) + Foreground(lipgloss.Color("#e13131")) newRowStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#31e131")) + Foreground(lipgloss.Color("#31e131")) ) type itemTableRow struct { @@ -37,15 +36,8 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { sb.WriteString("\t") } - switch colVal := mtr.item[colName].(type) { - case nil: - sb.WriteString("(nil)") - case *types.AttributeValueMemberS: - sb.WriteString(colVal.Value) - case *types.AttributeValueMemberN: - sb.WriteString(colVal.Value) - default: - sb.WriteString("(other)") + if r := mtr.item.Renderer(colName); r != nil { + sb.WriteString(r.StringValue()) } } diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index df59068..34a5544 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -5,6 +5,7 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/google/uuid" "log" + "strconv" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -66,13 +67,22 @@ func main() { for i := 0; i < totalItems; i++ { key := uuid.New().String() if err := tableService.Put(ctx, tableInfo, models.Item{ - "pk": &types.AttributeValueMemberS{Value: key}, - "sk": &types.AttributeValueMemberS{Value: key}, - "name": &types.AttributeValueMemberS{Value: gofakeit.Name()}, - "address": &types.AttributeValueMemberS{Value: gofakeit.Address().Address}, - "city": &types.AttributeValueMemberS{Value: gofakeit.Address().City}, - "phone": &types.AttributeValueMemberS{Value: gofakeit.Phone()}, - "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, + "pk": &types.AttributeValueMemberS{Value: key}, + "sk": &types.AttributeValueMemberS{Value: key}, + "name": &types.AttributeValueMemberS{Value: gofakeit.Name()}, + "address": &types.AttributeValueMemberS{Value: gofakeit.Address().Address}, + "city": &types.AttributeValueMemberS{Value: gofakeit.Address().City}, + "phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()}, + "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, + "inOffice": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, + "ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, + &types.AttributeValueMemberN{Value: strconv.Itoa(int(gofakeit.Int32()))}, + }}, + "values": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "adverb": &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, + "int": &types.AttributeValueMemberN{Value: strconv.Itoa(int(gofakeit.Int32()))}, + }}, }); err != nil { log.Fatalln(err) } From 0fb641cdfdef528a0ad49f2608ba98dc8d54c2f5 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 2 Jun 2022 22:39:47 +1000 Subject: [PATCH 12/26] Added some colours --- .../dynamo-browse/models/itemrender/coll.go | 8 ++++ .../models/itemrender/itemdisp.go | 1 + .../dynamo-browse/models/itemrender/nils.go | 10 +++-- .../models/itemrender/scalars.go | 26 ++++++++--- .../dynamo-browse/models/itemrender/sets.go | 4 ++ .../ui/teamodels/dynamoitemview/model.go | 14 ++++-- .../ui/teamodels/dynamotableview/model.go | 44 ++++++++++++++++--- .../ui/teamodels/dynamotableview/tblmodel.go | 32 +++++++++----- 8 files changed, 110 insertions(+), 29 deletions(-) diff --git a/internal/dynamo-browse/models/itemrender/coll.go b/internal/dynamo-browse/models/itemrender/coll.go index 2277e5b..3a015e1 100644 --- a/internal/dynamo-browse/models/itemrender/coll.go +++ b/internal/dynamo-browse/models/itemrender/coll.go @@ -13,6 +13,10 @@ func (sr *ListRenderer) TypeName() string { } func (sr *ListRenderer) StringValue() string { + return "" +} + +func (sr *ListRenderer) MetaInfo() string { if len(sr.Value) == 1 { return fmt.Sprintf("(1 item)") } @@ -34,6 +38,10 @@ func (sr *MapRenderer) TypeName() string { } func (sr *MapRenderer) StringValue() string { + return "" +} + +func (sr *MapRenderer) MetaInfo() string { if len(sr.Value) == 1 { return fmt.Sprintf("(1 item)") } diff --git a/internal/dynamo-browse/models/itemrender/itemdisp.go b/internal/dynamo-browse/models/itemrender/itemdisp.go index 47043c5..dd1f17f 100644 --- a/internal/dynamo-browse/models/itemrender/itemdisp.go +++ b/internal/dynamo-browse/models/itemrender/itemdisp.go @@ -5,6 +5,7 @@ import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" type Renderer interface { TypeName() string StringValue() string + MetaInfo() string SubItems() []SubItem } diff --git a/internal/dynamo-browse/models/itemrender/nils.go b/internal/dynamo-browse/models/itemrender/nils.go index 27a4388..9171f31 100644 --- a/internal/dynamo-browse/models/itemrender/nils.go +++ b/internal/dynamo-browse/models/itemrender/nils.go @@ -3,11 +3,15 @@ package itemrender type OtherRenderer struct{} func (u OtherRenderer) TypeName() string { - return "(other)" + return "??" } -func (u OtherRenderer) StringValue() string { - return "(other)" +func (sr OtherRenderer) StringValue() string { + return "" +} + +func (u OtherRenderer) MetaInfo() string { + return "(unrecognised)" } func (u OtherRenderer) SubItems() []SubItem { diff --git a/internal/dynamo-browse/models/itemrender/scalars.go b/internal/dynamo-browse/models/itemrender/scalars.go index f9c9b10..9f67fe5 100644 --- a/internal/dynamo-browse/models/itemrender/scalars.go +++ b/internal/dynamo-browse/models/itemrender/scalars.go @@ -1,7 +1,6 @@ package itemrender import ( - "fmt" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) @@ -15,6 +14,10 @@ func (sr *StringRenderer) StringValue() string { return sr.Value } +func (sr *StringRenderer) MetaInfo() string { + return "" +} + func (sr *StringRenderer) SubItems() []SubItem { return nil } @@ -29,6 +32,10 @@ func (sr *NumberRenderer) StringValue() string { return sr.Value } +func (sr *NumberRenderer) MetaInfo() string { + return "" +} + func (sr *NumberRenderer) SubItems() []SubItem { return nil } @@ -46,6 +53,10 @@ func (sr *BoolRenderer) StringValue() string { return "False" } +func (sr *BoolRenderer) MetaInfo() string { + return "" +} + func (sr *BoolRenderer) SubItems() []SubItem { return nil } @@ -57,10 +68,11 @@ func (sr *BinaryRenderer) TypeName() string { } func (sr *BinaryRenderer) StringValue() string { - if len(sr.Value) == 1 { - return fmt.Sprintf("(1 byte)") - } - return fmt.Sprintf("(%d bytes)", len(sr.Value)) + return "" +} + +func (sr *BinaryRenderer) MetaInfo() string { + return cardinality(len(sr.Value), "byte", "bytes") } func (sr *BinaryRenderer) SubItems() []SubItem { @@ -73,6 +85,10 @@ func (sr *NullRenderer) TypeName() string { return "NULL" } +func (sr *NullRenderer) MetaInfo() string { + return "" +} + func (sr *NullRenderer) StringValue() string { return "null" } diff --git a/internal/dynamo-browse/models/itemrender/sets.go b/internal/dynamo-browse/models/itemrender/sets.go index 4885a32..209b1fa 100644 --- a/internal/dynamo-browse/models/itemrender/sets.go +++ b/internal/dynamo-browse/models/itemrender/sets.go @@ -15,6 +15,10 @@ func (sr *GenericRenderer) TypeName() string { } func (sr *GenericRenderer) StringValue() string { + return "" +} + +func (sr *GenericRenderer) MetaInfo() string { return cardinality(len(sr.subitemValue), "item", "items") } diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index 4109f7b..dd4a5c6 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -17,9 +17,14 @@ 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")) + + fieldTypeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#31e131")) + metaInfoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")) ) type Model struct { @@ -96,7 +101,8 @@ func (m *Model) updateViewportToSelectedMessage() { } func (m *Model) renderItem(w io.Writer, prefix string, name string, r itemrender.Renderer) { - fmt.Fprintf(w, "%s%v\t%s\t%s\n", prefix, name, r.TypeName(), r.StringValue()) + fmt.Fprintf(w, "%s%v\t%s\t%s%s\n", + prefix, name, fieldTypeStyle.Render(r.TypeName()), r.StringValue(), metaInfoStyle.Render(r.MetaInfo())) if subitems := r.SubItems(); len(subitems) > 0 { for _, si := range subitems { m.renderItem(w, prefix+" ", si.Key, si.Value) diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index c12bd7a..6e51a22 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -24,6 +24,7 @@ type Model struct { w, h int // model state + colOffset int rows []table.Row resultSet *models.ResultSet } @@ -60,6 +61,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "k", "down": m.table.GoDown() return m, m.postSelectedItemChanged + case "j": + m.setLeftmostDisplayedColumn(m.colOffset - 1) + return m, nil + case "l": + m.setLeftmostDisplayedColumn(m.colOffset + 1) + return m, nil case "I", "pgup": m.table.GoPageUp() return m, m.postSelectedItemChanged @@ -72,6 +79,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) setLeftmostDisplayedColumn(newCol int) { + if newCol < 0 { + m.colOffset = 0 + } else if newCol >= len(m.resultSet.Columns) { + m.colOffset = len(m.resultSet.Columns) - 1 + } else { + m.colOffset = newCol + } + m.rebuildTable() +} + func (m *Model) View() string { return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View()) } @@ -85,23 +103,39 @@ func (m *Model) Resize(w, h int) layout.ResizingModel { } func (m *Model) updateTable() { + m.colOffset = 0 + + m.frameTitle.SetTitle("Table: " + m.resultSet.TableInfo.Name) + m.rebuildTable() +} + +func (m *Model) rebuildTable() { resultSet := m.resultSet - m.frameTitle.SetTitle("Table: " + resultSet.TableInfo.Name) - - newTbl := table.New(resultSet.Columns, m.w, m.h-m.frameTitle.HeaderHeight()) + newTbl := table.New(resultSet.Columns[m.colOffset:], m.w, m.h-m.frameTitle.HeaderHeight()) newRows := make([]table.Row, 0) for i, r := range resultSet.Items() { if resultSet.Hidden(i) { continue } - newRows = append(newRows, itemTableRow{resultSet: resultSet, itemIndex: i, item: r}) + newRows = append(newRows, itemTableRow{ + resultSet: resultSet, + itemIndex: i, + colOffset: m.colOffset, + item: r, + }) } m.rows = newRows newTbl.SetRows(newRows) - + for newTbl.Cursor() != m.table.Cursor() { + if newTbl.Cursor() < m.table.Cursor() { + newTbl.GoDown() + } else if newTbl.Cursor() > m.table.Cursor() { + newTbl.GoUp() + } + } m.table = newTbl } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index e3a726e..4ef82f4 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -17,11 +17,15 @@ var ( Foreground(lipgloss.Color("#e13131")) newRowStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#31e131")) + + metaInfoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")) ) type itemTableRow struct { resultSet *models.ResultSet itemIndex int + colOffset int item models.Item } @@ -30,17 +34,6 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { isDirty := mtr.resultSet.IsDirty(mtr.itemIndex) isNew := mtr.resultSet.IsNew(mtr.itemIndex) - sb := strings.Builder{} - for i, colName := range mtr.resultSet.Columns { - if i > 0 { - sb.WriteString("\t") - } - - if r := mtr.item.Renderer(colName); r != nil { - sb.WriteString(r.StringValue()) - } - } - var style lipgloss.Style if index == model.Cursor() { @@ -54,6 +47,21 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { } else if isDirty { style = style.Copy().Inherit(dirtyRowStyle) } + metaInfoStyle := style.Copy().Inherit(metaInfoStyle) - fmt.Fprintln(w, style.Render(sb.String())) + sb := strings.Builder{} + for i, colName := range mtr.resultSet.Columns[mtr.colOffset:] { + if i > 0 { + sb.WriteString(style.Render("\t")) + } + + if r := mtr.item.Renderer(colName); r != nil { + sb.WriteString(style.Render(r.StringValue())) + if mi := r.MetaInfo(); mi != "" { + sb.WriteString(metaInfoStyle.Render(mi)) + } + } + } + + fmt.Fprintln(w, sb.String()) } From 4aac153edbd0cc860e3b4a6d1b0edccd51b44443 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 3 Jun 2022 15:39:12 +1000 Subject: [PATCH 13/26] Small confirmation to check if numbers can contain decimals --- internal/dynamo-browse/ui/model.go | 7 +------ test/cmd/load-test-table/main.go | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index e5fb64f..7d4081b 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -51,12 +51,7 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon // TEMP "new-item": commandctrl.NoArgCommand(wc.NewItem()), - "set-string": func(args []string) tea.Cmd { - if len(args) != 1 { - return events.SetError(errors.New("expected attribute key")) - } - return wc.SetStringValue(dtv.SelectedItemIndex(), args[0]) - }, + "put": func(args []string) tea.Cmd { return wc.PutItem(dtv.SelectedItemIndex()) }, diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index 34a5544..dba36e8 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -77,7 +77,7 @@ func main() { "inOffice": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, "ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{ &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, - &types.AttributeValueMemberN{Value: strconv.Itoa(int(gofakeit.Int32()))}, + &types.AttributeValueMemberN{Value: "12.34"}, }}, "values": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ "adverb": &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, From d6606086263ff05c28269f61434f52a6bf1a6b4c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 4 Jun 2022 08:39:05 +1000 Subject: [PATCH 14/26] Some more work trying to get the contrast of the field types right --- .../ui/teamodels/dynamoitemview/model.go | 2 +- .../ui/teamodels/dynamotableview/model.go | 51 +++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index dd4a5c6..bcc65cf 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -22,7 +22,7 @@ var ( Background(lipgloss.Color("#4479ff")) fieldTypeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#31e131")) + Foreground(lipgloss.AdaptiveColor{Light: "#2B800C", Dark: "#73C653"}) metaInfoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")) ) diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 6e51a22..c3b3777 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -2,6 +2,7 @@ package dynamotableview import ( table "github.com/calyptia/go-bubble-table" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/controllers" @@ -18,10 +19,22 @@ var ( Background(lipgloss.Color("#4479ff")) ) +type KeyBinding struct { + MoveUp key.Binding + MoveDown key.Binding + PageUp key.Binding + PageDown key.Binding + Home key.Binding + End key.Binding + ColLeft key.Binding + ColRight key.Binding +} + type Model struct { frameTitle frame.FrameTitle table table.Model w, h int + keyBinding KeyBinding // model state colOffset int @@ -39,6 +52,16 @@ func New() *Model { return &Model{ frameTitle: frameTitle, table: tbl, + keyBinding: KeyBinding{ + MoveUp: key.NewBinding(key.WithKeys("i", "up")), + MoveDown: key.NewBinding(key.WithKeys("k", "down")), + PageUp: key.NewBinding(key.WithKeys("I", "pgup")), + PageDown: key.NewBinding(key.WithKeys("K", "pgdown")), + Home: key.NewBinding(key.WithKeys("I", "home")), + End: key.NewBinding(key.WithKeys("K", "end")), + ColLeft: key.NewBinding(key.WithKeys("j", "left")), + ColRight: key.NewBinding(key.WithKeys("l", "right")), + }, } } @@ -53,26 +76,32 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTable() return m, m.postSelectedItemChanged case tea.KeyMsg: - switch msg.String() { + switch { // Table nav - case "i", "up": + case key.Matches(msg, m.keyBinding.MoveUp): m.table.GoUp() return m, m.postSelectedItemChanged - case "k", "down": + case key.Matches(msg, m.keyBinding.MoveDown): m.table.GoDown() return m, m.postSelectedItemChanged - case "j": - m.setLeftmostDisplayedColumn(m.colOffset - 1) - return m, nil - case "l": - m.setLeftmostDisplayedColumn(m.colOffset + 1) - return m, nil - case "I", "pgup": + case key.Matches(msg, m.keyBinding.PageUp): m.table.GoPageUp() return m, m.postSelectedItemChanged - case "K", "pgdn": + case key.Matches(msg, m.keyBinding.PageDown): m.table.GoPageDown() return m, m.postSelectedItemChanged + case key.Matches(msg, m.keyBinding.Home): + m.table.GoTop() + return m, m.postSelectedItemChanged + case key.Matches(msg, m.keyBinding.End): + m.table.GoBottom() + return m, m.postSelectedItemChanged + case key.Matches(msg, m.keyBinding.ColLeft): + m.setLeftmostDisplayedColumn(m.colOffset - 1) + return m, nil + case key.Matches(msg, m.keyBinding.ColRight): + m.setLeftmostDisplayedColumn(m.colOffset + 1) + return m, nil } } From a4216b47f544e4ddb308453f862b240af2f7eff9 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 6 Jun 2022 20:47:04 +1000 Subject: [PATCH 15/26] Fixed port for test Docker container --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 54bb1b9..7570131 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: postgres: image: amazon/dynamodb-local:latest ports: - - 8000:8000 + - 18000:8000 steps: - name: Checkout uses: actions/checkout@v2 From e5a7b82a6329749f8b36fceed6f25e30f266b650 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 9 Jun 2022 20:33:19 +1000 Subject: [PATCH 16/26] Prompt user for primary and secondary key for new items --- go.mod | 15 ++++---- go.sum | 22 +++++++++++ .../dynamo-browse/controllers/commands.go | 25 ++++++++++++ .../controllers/tableread_test.go | 32 +++++++++++++++- .../dynamo-browse/controllers/tablewrite.go | 33 +++++++++++++--- .../controllers/tablewrite_test.go | 21 ++++++---- .../providers/dynamo/provider.go | 26 +++++++++---- .../dynamo-browse/services/tables/iface.go | 2 +- .../dynamo-browse/services/tables/service.go | 2 +- internal/dynamo-browse/ui/model.go | 6 +++ .../ui/teamodels/dynamotableview/model.go | 38 +++++++++++++------ .../ui/teamodels/dynamotableview/tblmodel.go | 8 ++-- internal/slog-view/ui/loglines/model.go | 2 +- internal/slog-view/ui/loglines/tblmodel.go | 2 +- internal/sqs-browse/ui/model.go | 2 +- internal/sqs-browse/ui/tblmodel.go | 2 +- internal/ssm-browse/ui/ssmlist/ssmlist.go | 2 +- internal/ssm-browse/ui/ssmlist/tblmodel.go | 2 +- test/cmd/load-test-table/main.go | 2 +- 19 files changed, 191 insertions(+), 53 deletions(-) create mode 100644 internal/dynamo-browse/controllers/commands.go diff --git a/go.mod b/go.mod index da7b341..f77add4 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 github.com/brianvoe/gofakeit/v6 v6.15.0 - github.com/calyptia/go-bubble-table v0.1.0 - github.com/charmbracelet/bubbles v0.10.3 - github.com/charmbracelet/bubbletea v0.20.0 + github.com/charmbracelet/bubbles v0.11.0 + github.com/charmbracelet/bubbletea v0.21.0 github.com/charmbracelet/lipgloss v0.5.0 github.com/google/uuid v1.3.0 github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e @@ -42,18 +41,20 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect + github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/cancelreader v0.2.0 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect + github.com/muesli/termenv v0.12.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect go.etcd.io/bbolt v1.3.6 // indirect - golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) diff --git a/go.sum b/go.sum index 527818a..c730d98 100644 --- a/go.sum +++ b/go.sum @@ -62,12 +62,19 @@ github.com/brianvoe/gofakeit/v6 v6.15.0 h1:lJPGJZ2/07TRGDazyTzD5b18N3y4tmmJpdhCU github.com/brianvoe/gofakeit/v6 v6.15.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= github.com/calyptia/go-bubble-table v0.1.0 h1:mXpaaBlrHGH4K8v5PvM8YqBFT9jlysS1YOycU2u3gEQ= github.com/calyptia/go-bubble-table v0.1.0/go.mod h1:2nnweuFos+eEIIbgweXvZuX+ROOatsMwB3NHnX/vTC4= +github.com/calyptia/go-bubble-table v0.2.1 h1:NWcVRyGCLuP7QIA29uUFSY+IjmWcmUWHjy5J/CPb0Rk= +github.com/calyptia/go-bubble-table v0.2.1/go.mod h1:gJvzUOUzfQeA9JmgLumyJYWJMtuRQ7WxxTwc9tjEiGw= github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q= +github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= +github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= @@ -93,6 +100,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGylMU1b+XnZponQKiPVNi+C/xgA= github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM= +github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239 h1:GGw5pZtEFnHtD7kKdWsiwgcIwZTnok60sShrHVYz4ok= +github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ= github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18= @@ -112,6 +121,10 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= @@ -119,6 +132,8 @@ github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtl github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= +github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -140,11 +155,18 @@ golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/dynamo-browse/controllers/commands.go b/internal/dynamo-browse/controllers/commands.go new file mode 100644 index 0000000..5b7c630 --- /dev/null +++ b/internal/dynamo-browse/controllers/commands.go @@ -0,0 +1,25 @@ +package controllers + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/events" +) + +type promptSequence struct { + prompts []string + receivedValues []string + onAllDone func(values []string) tea.Msg +} + +func (ps *promptSequence) next() tea.Msg { + if len(ps.receivedValues) < len(ps.prompts) { + return events.PromptForInputMsg{ + Prompt: ps.prompts[len(ps.receivedValues)], + OnDone: func(value string) tea.Cmd { + ps.receivedValues = append(ps.receivedValues, value) + return ps.next + }, + } + } + return ps.onAllDone(ps.receivedValues) +} diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index d061394..74ad025 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -114,13 +114,14 @@ func tempFile(t *testing.T) string { return tempFile.Name() } -func invokeCommand(t *testing.T, cmd tea.Cmd) { +func invokeCommand(t *testing.T, cmd tea.Cmd) tea.Msg { msg := cmd() err, isErr := msg.(events.ErrorMsg) if isErr { assert.Fail(t, fmt.Sprintf("expected no error but got one: %v", err)) } + return msg } func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) { @@ -134,6 +135,35 @@ func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) { invokeCommand(t, pi.OnDone(promptValue)) } +func invokeCommandWithPrompts(t *testing.T, cmd tea.Cmd, promptValues ...string) { + msg := cmd() + + for _, promptValue := range promptValues { + pi, isPi := msg.(events.PromptForInputMsg) + if !isPi { + assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one")) + } + + msg = invokeCommand(t, pi.OnDone(promptValue)) + } +} + +func invokeCommandWithPromptsExpectingError(t *testing.T, cmd tea.Cmd, promptValues ...string) { + msg := cmd() + + for _, promptValue := range promptValues { + pi, isPi := msg.(events.PromptForInputMsg) + if !isPi { + assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one")) + } + + msg = invokeCommand(t, pi.OnDone(promptValue)) + } + + _, isErr := msg.(events.ErrorMsg) + assert.True(t, isErr) +} + func invokeCommandExpectingError(t *testing.T, cmd tea.Cmd) { msg := cmd() diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index ca6abd6..7c90d90 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -37,13 +37,34 @@ func (twc *TableWriteController) ToggleMark(idx int) tea.Cmd { func (twc *TableWriteController) NewItem() tea.Cmd { return func() tea.Msg { - twc.state.withResultSet(func(set *models.ResultSet) { - set.AddNewItem(models.Item{}, models.ItemAttribute{ - New: true, - Dirty: true, + // Work out which keys we need to prompt for + rs := twc.state.ResultSet() + + keyPrompts := &promptSequence{ + prompts: []string{rs.TableInfo.Keys.PartitionKey + ": "}, + } + if rs.TableInfo.Keys.SortKey != "" { + keyPrompts.prompts = append(keyPrompts.prompts, rs.TableInfo.Keys.SortKey+": ") + } + keyPrompts.onAllDone = func(values []string) tea.Msg { + twc.state.withResultSet(func(set *models.ResultSet) { + newItem := models.Item{} + + // TODO: deal with keys of different type + newItem[rs.TableInfo.Keys.PartitionKey] = &types.AttributeValueMemberS{Value: values[0]} + if len(values) == 2 { + newItem[rs.TableInfo.Keys.SortKey] = &types.AttributeValueMemberS{Value: values[1]} + } + + set.AddNewItem(newItem, models.ItemAttribute{ + New: true, + Dirty: true, + }) }) - }) - return NewResultSet{twc.state.ResultSet()} + return NewResultSet{twc.state.ResultSet()} + } + + return keyPrompts.next() } } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 3eea9a5..de08093 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -181,13 +181,13 @@ func setupController(t *testing.T) (*controllers.TableWriteController, controlle */ func TestTableWriteController_NewItem(t *testing.T) { - client, cleanupFn := testdynamo.SetupTestTable(t, testData) - defer cleanupFn() + 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) + defer cleanupFn() - provider := dynamo.NewProvider(client) - service := tables.NewService(provider) + 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) { state := controllers.NewState() readController := controllers.NewTableReadController(state, service, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) @@ -195,10 +195,17 @@ func TestTableWriteController_NewItem(t *testing.T) { invokeCommand(t, readController.Init()) assert.Len(t, state.ResultSet().Items(), 3) - invokeCommand(t, writeController.NewItem()) + // Prompt for keys + invokeCommandWithPrompts(t, writeController.NewItem(), "pk-value", "sk-value") + newResultSet := state.ResultSet() assert.Len(t, newResultSet.Items(), 4) - assert.Len(t, newResultSet.Items()[3], 0) + assert.Len(t, newResultSet.Items()[3], 2) + + pk, _ := newResultSet.Items()[3].AttributeValueAsString("pk") + sk, _ := newResultSet.Items()[3].AttributeValueAsString("sk") + assert.Equal(t, "pk-value", pk) + assert.Equal(t, "sk-value", sk) assert.True(t, newResultSet.IsNew(3)) assert.True(t, newResultSet.IsDirty(3)) }) diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go index 4c74d74..1e46e72 100644 --- a/internal/dynamo-browse/providers/dynamo/provider.go +++ b/internal/dynamo-browse/providers/dynamo/provider.go @@ -64,17 +64,27 @@ func NewProvider(client *dynamodb.Client) *Provider { return &Provider{client: client} } -func (p *Provider) ScanItems(ctx context.Context, tableName string) ([]models.Item, error) { - res, err := p.client.Scan(ctx, &dynamodb.ScanInput{ +func (p *Provider) ScanItems(ctx context.Context, tableName string, maxItems int) ([]models.Item, error) { + paginator := dynamodb.NewScanPaginator(p.client, &dynamodb.ScanInput{ TableName: aws.String(tableName), + Limit: aws.Int32(int32(maxItems)), }) - if err != nil { - return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName) - } - items := make([]models.Item, len(res.Items)) - for i, itm := range res.Items { - items[i] = itm + items := make([]models.Item, 0) + +outer: + for paginator.HasMorePages() { + res, err := paginator.NextPage(ctx) + if err != nil { + return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName) + } + + for _, itm := range res.Items { + items = append(items, itm) + if len(items) >= maxItems { + break outer + } + } } return items, nil diff --git a/internal/dynamo-browse/services/tables/iface.go b/internal/dynamo-browse/services/tables/iface.go index 2dddc6c..8548115 100644 --- a/internal/dynamo-browse/services/tables/iface.go +++ b/internal/dynamo-browse/services/tables/iface.go @@ -10,7 +10,7 @@ import ( type TableProvider interface { ListTables(ctx context.Context) ([]string, error) DescribeTable(ctx context.Context, tableName string) (*models.TableInfo, error) - ScanItems(ctx context.Context, tableName string) ([]models.Item, error) + ScanItems(ctx context.Context, tableName string, maxItems int) ([]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 } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index 8e3a54d..f6b56e5 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -28,7 +28,7 @@ func (s *Service) Describe(ctx context.Context, table string) (*models.TableInfo } func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) { - results, err := s.provider.ScanItems(ctx, tableInfo.Name) + results, err := s.provider.ScanItems(ctx, tableInfo.Name, 1000) if err != nil { return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 7d4081b..2e9d9ba 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -51,6 +51,12 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon // TEMP "new-item": commandctrl.NoArgCommand(wc.NewItem()), + "set-s": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected field")) + } + return wc.SetStringValue(dtv.SelectedItemIndex(), args[0]) + }, "put": func(args []string) tea.Cmd { return wc.PutItem(dtv.SelectedItemIndex()) diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index c3b3777..971e99d 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -1,7 +1,6 @@ package dynamotableview import ( - table "github.com/calyptia/go-bubble-table" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -10,6 +9,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + table "github.com/lmika/go-bubble-table" ) var ( @@ -42,8 +42,20 @@ type Model struct { resultSet *models.ResultSet } +type columnModel struct { + m *Model +} + +func (cm columnModel) Len() int { + return len(cm.m.resultSet.Columns[cm.m.colOffset:]) +} + +func (cm columnModel) Header(index int) string { + return cm.m.resultSet.Columns[cm.m.colOffset+index] +} + func New() *Model { - tbl := table.New([]string{"pk", "sk"}, 100, 100) + tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100) rows := make([]table.Row, 0) tbl.SetRows(rows) @@ -116,7 +128,9 @@ func (m *Model) setLeftmostDisplayedColumn(newCol int) { } else { m.colOffset = newCol } - m.rebuildTable() + // TEMP + m.table.GoDown() + m.table.GoUp() } func (m *Model) View() string { @@ -141,7 +155,7 @@ func (m *Model) updateTable() { func (m *Model) rebuildTable() { resultSet := m.resultSet - newTbl := table.New(resultSet.Columns[m.colOffset:], m.w, m.h-m.frameTitle.HeaderHeight()) + newTbl := table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight()) newRows := make([]table.Row, 0) for i, r := range resultSet.Items() { if resultSet.Hidden(i) { @@ -149,22 +163,24 @@ func (m *Model) rebuildTable() { } newRows = append(newRows, itemTableRow{ + model: m, resultSet: resultSet, itemIndex: i, - colOffset: m.colOffset, item: r, }) } m.rows = newRows newTbl.SetRows(newRows) - for newTbl.Cursor() != m.table.Cursor() { - if newTbl.Cursor() < m.table.Cursor() { - newTbl.GoDown() - } else if newTbl.Cursor() > m.table.Cursor() { - newTbl.GoUp() + /* + for newTbl.Cursor() != m.table.Cursor() { + if newTbl.Cursor() < m.table.Cursor() { + newTbl.GoDown() + } else if newTbl.Cursor() > m.table.Cursor() { + newTbl.GoUp() + } } - } + */ m.table = newTbl } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index 4ef82f4..69d599f 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -6,13 +6,13 @@ import ( "io" "strings" - table "github.com/calyptia/go-bubble-table" "github.com/lmika/awstools/internal/dynamo-browse/models" + table "github.com/lmika/go-bubble-table" ) var ( markedRowStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("#e1e1e1")) + Background(lipgloss.AdaptiveColor{Dark: "#e1e1e1", Light: "#414141"}) dirtyRowStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#e13131")) newRowStyle = lipgloss.NewStyle(). @@ -23,9 +23,9 @@ var ( ) type itemTableRow struct { + model *Model resultSet *models.ResultSet itemIndex int - colOffset int item models.Item } @@ -50,7 +50,7 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { metaInfoStyle := style.Copy().Inherit(metaInfoStyle) sb := strings.Builder{} - for i, colName := range mtr.resultSet.Columns[mtr.colOffset:] { + for i, colName := range mtr.resultSet.Columns[mtr.model.colOffset:] { if i > 0 { sb.WriteString(style.Render("\t")) } diff --git a/internal/slog-view/ui/loglines/model.go b/internal/slog-view/ui/loglines/model.go index dd878aa..10fc6c1 100644 --- a/internal/slog-view/ui/loglines/model.go +++ b/internal/slog-view/ui/loglines/model.go @@ -1,7 +1,7 @@ package loglines import ( - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" diff --git a/internal/slog-view/ui/loglines/tblmodel.go b/internal/slog-view/ui/loglines/tblmodel.go index 6adc5d8..2cd53ee 100644 --- a/internal/slog-view/ui/loglines/tblmodel.go +++ b/internal/slog-view/ui/loglines/tblmodel.go @@ -2,7 +2,7 @@ package loglines import ( "fmt" - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" "github.com/lmika/awstools/internal/slog-view/models" "io" "strings" diff --git a/internal/sqs-browse/ui/model.go b/internal/sqs-browse/ui/model.go index 3063cab..f60c69b 100644 --- a/internal/sqs-browse/ui/model.go +++ b/internal/sqs-browse/ui/model.go @@ -7,7 +7,7 @@ import ( "log" "strings" - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" diff --git a/internal/sqs-browse/ui/tblmodel.go b/internal/sqs-browse/ui/tblmodel.go index b4fc15c..0eff075 100644 --- a/internal/sqs-browse/ui/tblmodel.go +++ b/internal/sqs-browse/ui/tblmodel.go @@ -5,7 +5,7 @@ import ( "io" "strings" - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" "github.com/lmika/awstools/internal/sqs-browse/models" ) diff --git a/internal/ssm-browse/ui/ssmlist/ssmlist.go b/internal/ssm-browse/ui/ssmlist/ssmlist.go index e304e66..b481148 100644 --- a/internal/ssm-browse/ui/ssmlist/ssmlist.go +++ b/internal/ssm-browse/ui/ssmlist/ssmlist.go @@ -1,7 +1,7 @@ package ssmlist import ( - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" diff --git a/internal/ssm-browse/ui/ssmlist/tblmodel.go b/internal/ssm-browse/ui/ssmlist/tblmodel.go index 6a28598..df35ccc 100644 --- a/internal/ssm-browse/ui/ssmlist/tblmodel.go +++ b/internal/ssm-browse/ui/ssmlist/tblmodel.go @@ -2,7 +2,7 @@ package ssmlist import ( "fmt" - table "github.com/calyptia/go-bubble-table" + table "github.com/lmika/go-bubble-table" "github.com/lmika/awstools/internal/ssm-browse/models" "io" "strings" diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index dba36e8..a09d047 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -20,7 +20,7 @@ import ( func main() { ctx := context.Background() tableName := "awstools-test" - totalItems := 10 + totalItems := 5000 cfg, err := config.LoadDefaultConfig(ctx) if err != nil { From 8d984119cc61b778fae19d5d8fd125588d5a60c1 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 11 Jun 2022 11:38:09 +1000 Subject: [PATCH 17/26] put-item: a few fixes - Added Goreleaser configuration - Changed some key bindings for the table list - Started working on full display of items --- .github/workflows/release.yaml | 52 +++++++++++++++++++ .goreleaser.yml | 45 ++++++++++++++++ .../ui/teamodels/itemdisplay/model.go | 37 +++++++++++++ .../ui/teamodels/tableselect/list.go | 17 ++++++ 4 files changed, 151 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 .goreleaser.yml create mode 100644 internal/dynamo-browse/ui/teamodels/itemdisplay/model.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..f5a333d --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,52 @@ +name: release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: amazon/dynamodb-local:latest + ports: + - 18000:8000 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Go + uses: actions/setup-go@v2 + with: + go-version: 1.18 + - name: Configure + run: | + git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika" + - name: Test + run: | + set -xue + go get ./... + go test ./... + env: + GOPRIVATE: "github:com/lmika/*" + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Go + uses: actions/setup-go@v2 + with: + go-version: 1.18 + - name: Configure + run: | + git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika" + - name: Release + uses: goreleaser/goreleaser-action@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + version: latest + args: release --skip-validate --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..f3a2051 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,45 @@ +builds: + - id: dynamo-browse + targets: + - windows_amd64 + - linux_amd64 + - darwin_amd64 + - darwin_arm64 + main: ./cmd/dynamo-browse/. + binary: dynamo-browse +archives: + - id: zip + builds: + - dynamo-browse + wrap_in_directory: true + format_overrides: + - goos: windows + format: zip + - goos: linux + format: tar.gz + - goos: macos + format: tar.gz +nfpms: + - id: package_nfpms + package_name: awstools + builds: + - dynamo-browse + vendor: lmika + homepage: https://awstools.lmika.dev/ + maintainer: Leon Mika + description: TUI tools for AWS administration + license: MIT + formats: + - deb + - rpm + bindir: /usr/local/bin +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' \ No newline at end of file diff --git a/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go b/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go new file mode 100644 index 0000000..3d31b7b --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/itemdisplay/model.go @@ -0,0 +1,37 @@ +package itemdisplay + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils" +) + +type Model struct { + baseMode tea.Model +} + +func New(baseMode tea.Model) *Model { + return &Model{ + baseMode: baseMode, + } +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cc utils.CmdCollector + + m.baseMode = cc.Collect(m.baseMode.Update(msg)) + return m, cc.Cmd() +} + +func (m *Model) View() string { + return m.baseMode.View() +} + +func (m *Model) Resize(w, h int) layout.ResizingModel { + m.baseMode = layout.Resize(m.baseMode, w, h) + return m +} diff --git a/internal/dynamo-browse/ui/teamodels/tableselect/list.go b/internal/dynamo-browse/ui/teamodels/tableselect/list.go index d5adbba..a8acad0 100644 --- a/internal/dynamo-browse/ui/teamodels/tableselect/list.go +++ b/internal/dynamo-browse/ui/teamodels/tableselect/list.go @@ -1,6 +1,7 @@ package tableselect import ( + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -32,6 +33,22 @@ func newListController(tableNames []string, w, h int) listController { Padding(0, 0, 0, 1) list := list.New(items, delegate, w, h) + list.KeyMap.CursorUp = key.NewBinding( + key.WithKeys("up", "i"), + key.WithHelp("↑/i", "up"), + ) + list.KeyMap.CursorDown = key.NewBinding( + key.WithKeys("down", "k"), + key.WithHelp("↓/k", "down"), + ) + list.KeyMap.PrevPage = key.NewBinding( + key.WithKeys("left", "j", "pgup", "b", "u"), + key.WithHelp("←/j/pgup", "prev page"), + ) + list.KeyMap.NextPage = key.NewBinding( + key.WithKeys("right", "l", "pgdown", "f", "d"), + key.WithHelp("→/l/pgdn", "next page"), + ) list.SetShowTitle(false) return listController{list: list} From 74e98d1c5d9025c092f91b1e19b7ea065059f57b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 11 Jun 2022 11:50:26 +1000 Subject: [PATCH 18/26] put-items: added brows to awstools --- .goreleaser.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index f3a2051..970b603 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -33,6 +33,15 @@ nfpms: - deb - rpm bindir: /usr/local/bin +brews: + - name: awstools + tap: + owner: lmika + name: awstools + folder: Formula + homepage: https://awstools.lmika.dev + description: TUI tools for AWS administration + license: MIT checksum: name_template: 'checksums.txt' snapshot: From 83c15bc369967e22740d3c13bf525005612d5555 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 11 Jun 2022 11:59:00 +1000 Subject: [PATCH 19/26] put-items: fixed tests --- .../dynamo-browse/providers/dynamo/provider_test.go | 10 +++++----- internal/slog-view/ui/loglines/model.go | 6 +++--- internal/sqs-browse/ui/model.go | 4 ++-- internal/ssm-browse/ui/ssmlist/ssmlist.go | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/dynamo-browse/providers/dynamo/provider_test.go b/internal/dynamo-browse/providers/dynamo/provider_test.go index 1540792..5d457b2 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) + items, err := provider.ScanItems(ctx, tableName, 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") + items, err := provider.ScanItems(ctx, "does-not-exist", 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) + items, err := provider.ScanItems(ctx, tableName, 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) + items, err := provider.ScanItems(ctx, tableName, 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") + items, err := provider.ScanItems(ctx, "does-not-exist", 100) assert.Error(t, err) assert.Nil(t, items) }) diff --git a/internal/slog-view/ui/loglines/model.go b/internal/slog-view/ui/loglines/model.go index 10fc6c1..f682919 100644 --- a/internal/slog-view/ui/loglines/model.go +++ b/internal/slog-view/ui/loglines/model.go @@ -1,12 +1,12 @@ package loglines import ( - table "github.com/lmika/go-bubble-table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/slog-view/models" + table "github.com/lmika/go-bubble-table" "path/filepath" ) @@ -28,7 +28,7 @@ type Model struct { func New() *Model { frameTitle := frame.NewFrameTitle("File: ", true, activeHeaderStyle) - table := table.New([]string{"level", "error", "message"}, 0, 0) + table := table.New(table.SimpleColumns{"level", "error", "message"}, 0, 0) return &Model{ frameTitle: frameTitle, @@ -40,7 +40,7 @@ func (m *Model) SetLogFile(newLogFile *models.LogFile) { m.logFile = newLogFile m.frameTitle.SetTitle("File: " + filepath.Base(newLogFile.Filename)) - cols := []string{"level", "error", "message"} + cols := table.SimpleColumns{"level", "error", "message"} newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight()) newRows := make([]table.Row, len(newLogFile.Lines)) diff --git a/internal/sqs-browse/ui/model.go b/internal/sqs-browse/ui/model.go index f60c69b..4e1657a 100644 --- a/internal/sqs-browse/ui/model.go +++ b/internal/sqs-browse/ui/model.go @@ -7,7 +7,6 @@ import ( "log" "strings" - table "github.com/lmika/go-bubble-table" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +15,7 @@ import ( "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/sqs-browse/controllers" "github.com/lmika/awstools/internal/sqs-browse/models" + table "github.com/lmika/go-bubble-table" ) var ( @@ -45,7 +45,7 @@ type uiModel struct { } func NewModel(dispatcher *dispatcher.Dispatcher, msgSendingHandlers *controllers.MessageSendingController) tea.Model { - tbl := table.New([]string{"seq", "message"}, 100, 20) + tbl := table.New(table.SimpleColumns{"seq", "message"}, 100, 20) rows := make([]table.Row, 0) tbl.SetRows(rows) diff --git a/internal/ssm-browse/ui/ssmlist/ssmlist.go b/internal/ssm-browse/ui/ssmlist/ssmlist.go index b481148..f909f27 100644 --- a/internal/ssm-browse/ui/ssmlist/ssmlist.go +++ b/internal/ssm-browse/ui/ssmlist/ssmlist.go @@ -1,12 +1,12 @@ package ssmlist import ( - table "github.com/lmika/go-bubble-table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/ssm-browse/models" + table "github.com/lmika/go-bubble-table" ) var ( @@ -27,7 +27,7 @@ type Model struct { func New() *Model { frameTitle := frame.NewFrameTitle("SSM: /", true, activeHeaderStyle) - table := table.New([]string{"name", "type", "value"}, 0, 0) + table := table.New(table.SimpleColumns{"name", "type", "value"}, 0, 0) return &Model{ frameTitle: frameTitle, @@ -41,7 +41,7 @@ func (m *Model) SetPrefix(newPrefix string) { func (m *Model) SetParameters(parameters *models.SSMParameters) { m.parameters = parameters - cols := []string{"name", "type", "value"} + cols := table.SimpleColumns{"name", "type", "value"} newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight()) newRows := make([]table.Row, len(parameters.Items)) From 47e404aff7c8b42db28f27d8cfeea148c7f67a71 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 11 Jun 2022 12:10:39 +1000 Subject: [PATCH 20/26] Turned off parallel test execution --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7570131..81dbfa9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,6 @@ jobs: run: | set -xue go get ./... - go test ./... + go test -p 1 ./... env: GOPRIVATE: "github:com/lmika/*" \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f5a333d..6a4cf66 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -27,7 +27,7 @@ jobs: run: | set -xue go get ./... - go test ./... + go test -p 1 ./... env: GOPRIVATE: "github:com/lmika/*" release: From 41af3992150dff904756c2b03927b3456f9050c1 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 16 Jun 2022 22:00:25 +1000 Subject: [PATCH 21/26] A few various changes - Fixed the '-local' flag to accept host and port - Added a '-debug' flag to accept a file to write debug log messages - Added some logic which will force the dark background flag on if MacOS is in dark mode --- cmd/dynamo-browse/main.go | 41 +++++++++++++++---- go.mod | 2 +- go.sum | 2 + internal/common/ui/events/commands.go | 9 ++++ internal/common/ui/logging/debug.go | 17 ++++---- internal/common/ui/osstyle/osstyle.go | 18 ++++++++ internal/common/ui/osstyle/osstyle_darwin.go | 27 ++++++++++++ internal/dynamo-browse/controllers/events.go | 8 ++-- .../dynamo-browse/controllers/tableread.go | 24 +++++++---- .../dynamo-browse/controllers/tablewrite.go | 2 +- .../ui/teamodels/dynamotableview/model.go | 13 +----- .../ui/teamodels/dynamotableview/tblmodel.go | 4 +- .../ssm-browse/controllers/ssmcontroller.go | 21 ++++++++++ .../ssm-browse/providers/awsssm/provider.go | 18 ++++++-- .../services/ssmparameters/iface.go | 1 + .../services/ssmparameters/service.go | 10 +++-- internal/ssm-browse/ui/model.go | 6 +++ test/cmd/load-test-table/main.go | 36 ++++++++-------- 18 files changed, 191 insertions(+), 68 deletions(-) create mode 100644 internal/common/ui/osstyle/osstyle.go create mode 100644 internal/common/ui/osstyle/osstyle_darwin.go diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index f56a40b..9cddadb 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -10,18 +10,21 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/common/ui/commandctrl" "github.com/lmika/awstools/internal/common/ui/logging" + "github.com/lmika/awstools/internal/common/ui/osstyle" "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" "github.com/lmika/awstools/internal/dynamo-browse/ui" "github.com/lmika/gopkgs/cli" "log" + "net" "os" ) func main() { var flagTable = flag.String("t", "", "dynamodb table name") - var flagLocal = flag.Bool("local", false, "local endpoint") + var flagLocal = flag.String("local", "", "local endpoint") + var flagDebug = flag.String("debug", "", "file to log debug messages") flag.Parse() ctx := context.Background() @@ -32,9 +35,19 @@ func main() { } var dynamoClient *dynamodb.Client - if *flagLocal { + if *flagLocal != "" { + host, port, err := net.SplitHostPort(*flagLocal) + if err != nil { + cli.Fatalf("invalid address '%v': %v", *flagLocal, err) + } + if host == "" { + host = "localhost" + } + if port == "" { + port = "8000" + } dynamoClient = dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:18000"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL(fmt.Sprintf("http://%v:%v", host, port)))) } else { dynamoClient = dynamodb.NewFromConfig(cfg) } @@ -50,14 +63,28 @@ func main() { commandController := commandctrl.NewCommandController() model := ui.NewModel(tableReadController, tableWriteController, commandController) - // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. - lipgloss.HasDarkBackground() - p := tea.NewProgram(model, tea.WithAltScreen()) - closeFn := logging.EnableLogging() + closeFn := logging.EnableLogging(*flagDebug) defer closeFn() + // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. + if lipgloss.HasDarkBackground() { + if colorScheme := osstyle.CurrentColorScheme(); colorScheme == osstyle.ColorSchemeLightMode { + log.Printf("terminal reads dark but really in light mode") + lipgloss.SetHasDarkBackground(true) + } else { + log.Printf("in dark background") + } + } else { + if colorScheme := osstyle.CurrentColorScheme(); colorScheme == osstyle.ColorSchemeDarkMode { + log.Printf("terminal reads light but really in dark mode") + lipgloss.SetHasDarkBackground(true) + } else { + log.Printf("cannot detect system darkmode") + } + } + log.Println("launching") if err := p.Start(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) diff --git a/go.mod b/go.mod index f77add4..fa569c3 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect - github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239 // indirect + github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mattn/go-isatty v0.0.14 // indirect diff --git a/go.sum b/go.sum index c730d98..b0ee31b 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGy github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM= github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239 h1:GGw5pZtEFnHtD7kKdWsiwgcIwZTnok60sShrHVYz4ok= github.com/lmika/go-bubble-table v0.2.2-0.20220608033210-61eeb29a6239/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg= +github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 h1:dtMPRNoDqDnnP3HgOvYhswcJVSqdISkYlCtGOjTqg6Q= +github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ= github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18= diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index 99f70a7..19857b4 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -31,6 +31,15 @@ func PromptForInput(prompt string, onDone func(value string) tea.Cmd) tea.Cmd { } } +func Confirm(prompt string, onYes func() tea.Cmd) tea.Cmd { + return PromptForInput(prompt, func(value string) tea.Cmd { + if value == "y" { + return onYes() + } + return nil + }) +} + type MessageWithStatus interface { StatusMessage() string } diff --git a/internal/common/ui/logging/debug.go b/internal/common/ui/logging/debug.go index 12ddea1..7ff1fc1 100644 --- a/internal/common/ui/logging/debug.go +++ b/internal/common/ui/logging/debug.go @@ -6,15 +6,18 @@ import ( "os" ) -func EnableLogging() (closeFn func()) { - tempFile, err := os.CreateTemp("", "debug.log") - if err != nil { - fmt.Println("fatal:", err) - os.Exit(1) +func EnableLogging(logFile string) (closeFn func()) { + if logFile == "" { + tempFile, err := os.CreateTemp("", "debug.log") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + tempFile.Close() + logFile = tempFile.Name() } - tempFile.Close() - f, err := tea.LogToFile(tempFile.Name(), "debug") + f, err := tea.LogToFile(logFile, "debug") if err != nil { fmt.Println("fatal:", err) os.Exit(1) diff --git a/internal/common/ui/osstyle/osstyle.go b/internal/common/ui/osstyle/osstyle.go new file mode 100644 index 0000000..e053f0b --- /dev/null +++ b/internal/common/ui/osstyle/osstyle.go @@ -0,0 +1,18 @@ +package osstyle + +type ColorScheme int + +const ( + ColorSchemeUnknown ColorScheme = iota + ColorSchemeLightMode + ColorSchemeDarkMode +) + +var getOSColorScheme func() ColorScheme = nil + +func CurrentColorScheme() ColorScheme { + if getOSColorScheme == nil { + return ColorSchemeUnknown + } + return getOSColorScheme() +} diff --git a/internal/common/ui/osstyle/osstyle_darwin.go b/internal/common/ui/osstyle/osstyle_darwin.go new file mode 100644 index 0000000..ef39429 --- /dev/null +++ b/internal/common/ui/osstyle/osstyle_darwin.go @@ -0,0 +1,27 @@ +package osstyle + +import ( + "log" + "os/exec" +) + +// Usage: https://stefan.sofa-rockers.org/2018/10/23/macos-dark-mode-terminal-vim/ +func darwinGetOSColorScheme() ColorScheme { + d, err := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle").Output() + if err != nil { + log.Printf("cannot get current OS color scheme: %v", err) + return ColorSchemeUnknown + } + + switch string(d) { + case "Dark\n": + return ColorSchemeDarkMode + case "Light\n": + return ColorSchemeLightMode + } + return ColorSchemeUnknown +} + +func init() { + getOSColorScheme = darwinGetOSColorScheme +} diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 441f05c..a460f5d 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -1,18 +1,18 @@ package controllers import ( - "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type NewResultSet struct { - ResultSet *models.ResultSet + ResultSet *models.ResultSet + statusMessage string } func (rs NewResultSet) StatusMessage() string { - return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) + //return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) + return rs.statusMessage } type SetReadWrite struct { diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 07bbea8..01048e5 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -3,6 +3,7 @@ package controllers import ( "context" "encoding/csv" + "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/models" @@ -126,16 +127,23 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu return c.setResultSetAndFilter(newResultSet, c.state.Filter()) } -//func (c *TableReadController) ResultSet() *models.ResultSet { -// c.mutex.Lock() -// defer c.mutex.Unlock() -// -// return c.resultSet -//} - func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { c.state.setResultSetAndFilter(resultSet, filter) - return NewResultSet{resultSet} + + var statusMessage string + if filter != "" { + var filteredCount int + for i := range resultSet.Items() { + if !resultSet.Hidden(i) { + filteredCount += 1 + } + } + statusMessage = fmt.Sprintf("%d of %d items returned", filteredCount, len(resultSet.Items())) + } else { + statusMessage = fmt.Sprintf("%d items returned", len(resultSet.Items())) + } + + return NewResultSet{resultSet, statusMessage} } func (c *TableReadController) Unmark() tea.Cmd { diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 7c90d90..60ea800 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -61,7 +61,7 @@ func (twc *TableWriteController) NewItem() tea.Cmd { Dirty: true, }) }) - return NewResultSet{twc.state.ResultSet()} + return NewResultSet{twc.state.ResultSet(), "New item added"} } return keyPrompts.next() diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 971e99d..fbdfdb4 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -128,9 +128,7 @@ func (m *Model) setLeftmostDisplayedColumn(newCol int) { } else { m.colOffset = newCol } - // TEMP - m.table.GoDown() - m.table.GoUp() + m.table.UpdateView() } func (m *Model) View() string { @@ -172,15 +170,6 @@ func (m *Model) rebuildTable() { m.rows = newRows newTbl.SetRows(newRows) - /* - for newTbl.Cursor() != m.table.Cursor() { - if newTbl.Cursor() < m.table.Cursor() { - newTbl.GoDown() - } else if newTbl.Cursor() > m.table.Cursor() { - newTbl.GoUp() - } - } - */ m.table = newTbl } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index 69d599f..2ecfafc 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -12,7 +12,7 @@ import ( var ( markedRowStyle = lipgloss.NewStyle(). - Background(lipgloss.AdaptiveColor{Dark: "#e1e1e1", Light: "#414141"}) + Background(lipgloss.AdaptiveColor{Light: "#e1e1e1", Dark: "#414141"}) dirtyRowStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#e13131")) newRowStyle = lipgloss.NewStyle(). @@ -60,6 +60,8 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { if mi := r.MetaInfo(); mi != "" { sb.WriteString(metaInfoStyle.Render(mi)) } + } else { + sb.WriteString(metaInfoStyle.Render("~")) } } diff --git a/internal/ssm-browse/controllers/ssmcontroller.go b/internal/ssm-browse/controllers/ssmcontroller.go index 4af7335..5f2a6b7 100644 --- a/internal/ssm-browse/controllers/ssmcontroller.go +++ b/internal/ssm-browse/controllers/ssmcontroller.go @@ -77,3 +77,24 @@ func (c *SSMController) Clone(param models.SSMParameter) tea.Cmd { } }) } + +func (c *SSMController) DeleteParameter(param models.SSMParameter) tea.Cmd { + return events.Confirm("delete parameter? ", func() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := c.service.Delete(ctx, param); err != nil { + return events.Error(err) + } + + res, err := c.service.List(context.Background(), c.prefix) + if err != nil { + return events.Error(err) + } + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } + } + }) +} diff --git a/internal/ssm-browse/providers/awsssm/provider.go b/internal/ssm-browse/providers/awsssm/provider.go index c7e72bb..ab5cd81 100644 --- a/internal/ssm-browse/providers/awsssm/provider.go +++ b/internal/ssm-browse/providers/awsssm/provider.go @@ -56,9 +56,9 @@ outer: func (p *Provider) Put(ctx context.Context, param models.SSMParameter, override bool) error { in := &ssm.PutParameterInput{ - Name: aws.String(param.Name), - Type: param.Type, - Value: aws.String(param.Value), + Name: aws.String(param.Name), + Type: param.Type, + Value: aws.String(param.Value), Overwrite: override, } if param.Type == types.ParameterTypeSecureString { @@ -71,4 +71,14 @@ func (p *Provider) Put(ctx context.Context, param models.SSMParameter, override } return nil -} \ No newline at end of file +} + +func (p *Provider) Delete(ctx context.Context, param models.SSMParameter) error { + _, err := p.client.DeleteParameter(ctx, &ssm.DeleteParameterInput{ + Name: aws.String(param.Name), + }) + if err != nil { + return errors.Wrap(err, "unable to delete SSM parameter") + } + return nil +} diff --git a/internal/ssm-browse/services/ssmparameters/iface.go b/internal/ssm-browse/services/ssmparameters/iface.go index 406d733..5a1249a 100644 --- a/internal/ssm-browse/services/ssmparameters/iface.go +++ b/internal/ssm-browse/services/ssmparameters/iface.go @@ -8,4 +8,5 @@ import ( type SSMProvider interface { List(ctx context.Context, prefix string, maxCount int) (*models.SSMParameters, error) Put(ctx context.Context, param models.SSMParameter, override bool) error + Delete(ctx context.Context, param models.SSMParameter) error } diff --git a/internal/ssm-browse/services/ssmparameters/service.go b/internal/ssm-browse/services/ssmparameters/service.go index 40f2042..19e8903 100644 --- a/internal/ssm-browse/services/ssmparameters/service.go +++ b/internal/ssm-browse/services/ssmparameters/service.go @@ -21,9 +21,13 @@ func (s *Service) List(ctx context.Context, prefix string) (*models.SSMParameter func (s *Service) Clone(ctx context.Context, param models.SSMParameter, newName string) error { newParam := models.SSMParameter{ - Name: newName, - Type: param.Type, + Name: newName, + Type: param.Type, Value: param.Value, } return s.provider.Put(ctx, newParam, false) -} \ No newline at end of file +} + +func (s *Service) Delete(ctx context.Context, param models.SSMParameter) error { + return s.provider.Delete(ctx, param) +} diff --git a/internal/ssm-browse/ui/model.go b/internal/ssm-browse/ui/model.go index e01fa2d..d3c315e 100644 --- a/internal/ssm-browse/ui/model.go +++ b/internal/ssm-browse/ui/model.go @@ -37,6 +37,12 @@ func NewModel(controller *controllers.SSMController, cmdController *commandctrl. } return events.SetError(errors.New("no parameter selected")) }, + "delete": func(args []string) tea.Cmd { + if currentParam := ssmList.CurrentParameter(); currentParam != nil { + return controller.DeleteParameter(*currentParam) + } + return events.SetError(errors.New("no parameter selected")) + }, }, }) diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index a09d047..51a1137 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -2,24 +2,23 @@ package main import ( "context" - "github.com/brianvoe/gofakeit/v6" - "github.com/google/uuid" - "log" - "strconv" - + "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/brianvoe/gofakeit/v6" + "github.com/google/uuid" "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" "github.com/lmika/gopkgs/cli" + "log" ) func main() { ctx := context.Background() - tableName := "awstools-test" + tableName := "business-addresses" totalItems := 5000 cfg, err := config.LoadDefaultConfig(ctx) @@ -67,21 +66,18 @@ func main() { for i := 0; i < totalItems; i++ { key := uuid.New().String() if err := tableService.Put(ctx, tableInfo, models.Item{ - "pk": &types.AttributeValueMemberS{Value: key}, - "sk": &types.AttributeValueMemberS{Value: key}, - "name": &types.AttributeValueMemberS{Value: gofakeit.Name()}, - "address": &types.AttributeValueMemberS{Value: gofakeit.Address().Address}, - "city": &types.AttributeValueMemberS{Value: gofakeit.Address().City}, - "phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()}, - "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, - "inOffice": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, + "pk": &types.AttributeValueMemberS{Value: key}, + "sk": &types.AttributeValueMemberS{Value: key}, + "name": &types.AttributeValueMemberS{Value: gofakeit.Name()}, + "address": &types.AttributeValueMemberS{Value: gofakeit.Address().Address}, + "city": &types.AttributeValueMemberS{Value: gofakeit.Address().City}, + "phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()}, + "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, + "officeOpened": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, "ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{ - &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, - &types.AttributeValueMemberN{Value: "12.34"}, - }}, - "values": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ - "adverb": &types.AttributeValueMemberS{Value: gofakeit.Adverb()}, - "int": &types.AttributeValueMemberN{Value: strconv.Itoa(int(gofakeit.Int32()))}, + &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, + &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, + &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, }}, }); err != nil { log.Fatalln(err) From 54fab1b1c31956a9a86e04d6195e4b041ef3e625 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 21 Jun 2022 13:37:07 +1000 Subject: [PATCH 22/26] dynamo-query: started working on queries --- go.mod | 19 +++---- go.sum | 21 ++++++++ internal/dynamo-browse/controllers/iface.go | 1 + .../dynamo-browse/controllers/tableread.go | 23 ++++++++ .../controllers/tableread_test.go | 34 ++++++++++++ internal/dynamo-browse/models/query.go | 8 +++ .../dynamo-browse/models/queryexpr/ast.go | 32 ++++++++++++ .../models/queryexpr/astquery.go | 52 +++++++++++++++++++ .../dynamo-browse/models/queryexpr/expr.go | 11 ++++ .../models/queryexpr/expr_test.go | 47 +++++++++++++++++ .../dynamo-browse/models/queryexpr/values.go | 24 +++++++++ .../providers/dynamo/provider.go | 15 ++++-- .../dynamo-browse/services/tables/iface.go | 3 +- .../dynamo-browse/services/tables/service.go | 27 +++++++++- internal/dynamo-browse/ui/model.go | 4 +- 15 files changed, 305 insertions(+), 16 deletions(-) create mode 100644 internal/dynamo-browse/models/query.go create mode 100644 internal/dynamo-browse/models/queryexpr/ast.go create mode 100644 internal/dynamo-browse/models/queryexpr/astquery.go create mode 100644 internal/dynamo-browse/models/queryexpr/expr.go create mode 100644 internal/dynamo-browse/models/queryexpr/expr_test.go create mode 100644 internal/dynamo-browse/models/queryexpr/values.go diff --git a/go.mod b/go.mod index fa569c3..6b98c88 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.18 require ( github.com/alecthomas/participle/v2 v2.0.0-alpha7 github.com/asdine/storm v2.1.2+incompatible - github.com/aws/aws-sdk-go-v2 v1.16.1 + github.com/aws/aws-sdk-go-v2 v1.16.5 github.com/aws/aws-sdk-go-v2/config v1.13.1 github.com/aws/aws-sdk-go-v2/credentials v1.8.0 - github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0 - github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.9.4 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.7 github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 github.com/brianvoe/gofakeit/v6 v6.15.0 github.com/charmbracelet/bubbles v0.11.0 @@ -25,18 +25,19 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 // indirect - github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0 // indirect + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.24.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 // indirect - github.com/aws/smithy-go v1.11.2 // indirect + github.com/aws/smithy-go v1.11.3 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect diff --git a/go.sum b/go.sum index b0ee31b..175f3fe 100644 --- a/go.sum +++ b/go.sum @@ -12,12 +12,18 @@ github.com/aws/aws-sdk-go-v2 v1.15.0 h1:f9kWLNfyCzCB43eupDAk3/XgJ2EpgktiySD6leqs github.com/aws/aws-sdk-go-v2 v1.15.0/go.mod h1:lJYcuZZEHWNIb6ugJjbQY1fykdoobWbOS7kJYb4APoI= github.com/aws/aws-sdk-go-v2 v1.16.1 h1:udzee98w8H6ikRgtFdVN9JzzYEbi/quFfSvduZETJIU= github.com/aws/aws-sdk-go-v2 v1.16.1/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2 v1.16.5 h1:Ah9h1TZD9E2S1LzHpViBO3Jz9FPL5+rmflmb8hXirtI= +github.com/aws/aws-sdk-go-v2 v1.16.5/go.mod h1:Wh7MEsmEApyL5hrWzpDkba4gwAPc5/piwLVLFnCxp48= github.com/aws/aws-sdk-go-v2/config v1.13.1 h1:yLv8bfNoT4r+UvUKQKqRtdnvuWGMK5a82l4ru9Jvnuo= github.com/aws/aws-sdk-go-v2/config v1.13.1/go.mod h1:Ba5Z4yL/UGbjQUzsiaN378YobhFo0MLfueXGiOsYtEs= github.com/aws/aws-sdk-go-v2/credentials v1.8.0 h1:8Ow0WcyDesGNL0No11jcgb1JAtE+WtubqXjgxau+S0o= github.com/aws/aws-sdk-go-v2/credentials v1.8.0/go.mod h1:gnMo58Vwx3Mu7hj1wpcG8DI0s57c9o42UQ6wgTQT5to= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0 h1:XxTy21xVUkoCZOSGwf+AW22v8aK3eEbYMaGGQ3MbKKk= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0/go.mod h1:6WkjzWenkrj3IgLPIPBBz4Qh99jNDF8L4Wj03vfMhAA= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.9.4 h1:EoyeSOfbSuKh+bQIDoZaVJjON6PF+dsSn5w1RhIpMD0= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.9.4/go.mod h1:bfCL7OwZS6owS06pahfGxhcgpLWj2W1sQASoYRuenag= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.10 h1:IBIZfpnWCTTQhH/bMvDcCMw10BtLBPYO30Ev8MLXMTY= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.4.10/go.mod h1:RL7aJOwlWj2N6wkE4nKR1S5M4iGph+xSu7JovwNYpyU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 h1:NITDuUZO34mqtOwFWZiXo7yAHj7kf+XPE+EiKuCBNUI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0/go.mod h1:I6/fHT/fH460v09eg2gVrd8B/IqskhNdpcLH0WNO3QI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 h1:CRiQJ4E2RhfDdqbie1ZYDo8QtIo75Mk7oTdJSfwJTMQ= @@ -26,22 +32,34 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 h1:xiGjGVQsem2cxoIX61 github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6/go.mod h1:SSPEdf9spsFgJyhjrXvawfpyzrXHBCUe+2eQ1CjC1Ak= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.8 h1:CDaO90VZVBAL1sK87S5oSPIrp7yZqORv1hPIi2UsTMk= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.8/go.mod h1:LnTQMTqbKsbtt+UI5+wPsB7jedW+2ZgozoPG8k6cMxg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12 h1:Zt7DDk5V7SyQULUUwIKzsROtVzp/kVvcz15uQx/Tkow= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.12/go.mod h1:Afj/U8svX6sJ77Q+FPWMzabJ9QjbwP32YlopgKALUpg= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 h1:3ADoioDMOtF4uiK59vCpplpCwugEU+v4ZFD29jDL3RQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 h1:bt3zw79tm209glISdMRCIVRCwvSDXxgAxh5KWe2qHkY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0/go.mod h1:viTrxhAuejD+LszDahzAE2x40YjYWhMqzHxv2ZiWaME= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.2 h1:XXR3cdOcKRCTZf6ctcqpMf+go1BdzTm6+T9Ul5zxcMI= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.2/go.mod h1:1x4ZP3Z8odssdhuLI+/1Tqw6Pt/VAaP4Tr8EUxHvPXE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6 h1:eeXdGVtXEe+2Jc49+/vAzna3FAQnUD4AagAw8tzbmfc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.6/go.mod h1:FwpAKI+FBPIELJIdmQzlLtRe8LQSOreMcM2wBsPMvvc= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 h1:ixotxbfTCFpqbuwFv/RcZwyzhkxPSYDYEMcj4niB5Uk= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5/go.mod h1:R3sWUqPcfXSiF/LSFJhjyJmpg9uV6yP2yv3YZZjldVI= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 h1:qnx+WyIH9/AD+wAxi05WCMNanO236ceqHg6hChCWs3M= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0/go.mod h1:+Kc1UmbE37ijaAsb3KogW6FR8z0myjX6VtdcCkQEK0k= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.7 h1:Ls6kDGWNr3wxE8JypXgTTonHpQ1eRVCGNqaFHY2UASw= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.7/go.mod h1:+v2jeT4/39fCXUQ0ZfHQHMMiJljnmiuj16F03uAd9DY= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0 h1:s71pGCiLqqGRoUWtdJ2j4PazwEpZVwQc16na/4FfXdk= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0/go.mod h1:YGzTq/joAih4HRZZtMBWGP4bI8xVucOBQ9RvuanpclA= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.7 h1:o2HKntJx3vr3y11NK58RA6tYKZKQo5PWWt/bs0rWR0U= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.7/go.mod h1:FAVtDKEl/8WxRDQ33e2fz16RO1t4zeEwWIU5kR29xXs= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 h1:uhb7moM7VjqIEpWzTpCvceLDSwrWpaleXm39OnVjuLE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0/go.mod h1:pA2St3Pu2Ldy6fBPY45Azoh1WBG4oS7eIKOd4XN7Meg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.2 h1:T/ywkX1ed+TsZVQccu/8rRJGxKZF/t0Ivgrb4MHTSeo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.2/go.mod h1:RnloUnyZ4KN9JStGY1LuQ7Wzqh7V0f8FinmRdHYtuaA= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0 h1:6Bc0KHhAyxGe15JUHrK+Udw7KhE5LN+5HKZjQGo4yDI= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0/go.mod h1:0nXuX9UrkN4r0PX9TSKfcueGRfsdEYIKG4rjTeJ61X8= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.6 h1:JGrc3+kkyr848/wpG2+kWuzHK3H4Fyxj2jnXj8ijQ/Y= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.6/go.mod h1:zwvTysbXES8GDwFcwCPB8NkC+bCdio1abH+E+BRe/xg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 h1:4QAOB3KrvI1ApJK14sliGr3Ie2pjyvNypn/lfzDHfUw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0/go.mod h1:K/qPe6AP2TGYv4l6n7c88zh9jWBDf6nHhvg1fx/EWfU= github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 h1:dzWS4r8E9bA0TesHM40FSAtedwpTVCuTsLI8EziSqyk= @@ -58,6 +76,8 @@ github.com/aws/smithy-go v1.11.1 h1:IQ+lPZVkSM3FRtyaDox41R8YS6iwPMYIreejOgPW49g= github.com/aws/smithy-go v1.11.1/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE= github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= +github.com/aws/smithy-go v1.11.3 h1:DQixirEFM9IaKxX1olZ3ke3nvxRS2xMDteKIDWxozW8= +github.com/aws/smithy-go v1.11.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/brianvoe/gofakeit/v6 v6.15.0 h1:lJPGJZ2/07TRGDazyTzD5b18N3y4tmmJpdhCUw18FlI= github.com/brianvoe/gofakeit/v6 v6.15.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= github.com/calyptia/go-bubble-table v0.1.0 h1:mXpaaBlrHGH4K8v5PvM8YqBFT9jlysS1YOycU2u3gEQ= @@ -87,6 +107,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go index 7c74234..fcd4af1 100644 --- a/internal/dynamo-browse/controllers/iface.go +++ b/internal/dynamo-browse/controllers/iface.go @@ -10,4 +10,5 @@ type TableReadService interface { Describe(ctx context.Context, table string) (*models.TableInfo, error) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet + ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) } diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 01048e5..c07d208 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -75,6 +75,29 @@ func (c *TableReadController) ScanTable(name string) tea.Cmd { } } +func (c *TableReadController) PromptForQuery() tea.Cmd { + return func() tea.Msg { + return events.PromptForInputMsg{ + Prompt: "query: ", + OnDone: func(value string) tea.Cmd { + if value == "" { + return c.Rescan() + } + + return func() tea.Msg { + resultSet := c.state.ResultSet() + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, value) + if err != nil { + return events.Error(err) + } + + return c.setResultSetAndFilter(newResultSet, "") + } + }, + } + } +} + func (c *TableReadController) Rescan() tea.Cmd { return func() tea.Msg { return c.doScan(context.Background(), c.state.ResultSet()) diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index 74ad025..d9cae53 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -100,6 +100,40 @@ func TestTableReadController_ExportCSV(t *testing.T) { // Hidden items? } +func TestTableReadController_Query(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + readController := controllers.NewTableReadController(controllers.NewState(), service, "alpha-table") + + t.Run("should run scan with filter based on user query", func(t *testing.T) { + tempFile := tempFile(t) + + invokeCommand(t, readController.Init()) + invokeCommandWithPrompts(t, readController.PromptForQuery(), `pk ^= "abc"`) + invokeCommand(t, readController.ExportCSV(tempFile)) + + bts, err := os.ReadFile(tempFile) + assert.NoError(t, err) + + 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", + }, "")) + }) + + t.Run("should return error if result set is not set", func(t *testing.T) { + tempFile := tempFile(t) + readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table") + + invokeCommandExpectingError(t, readController.Init()) + invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) + }) +} + func tempFile(t *testing.T) string { t.Helper() diff --git a/internal/dynamo-browse/models/query.go b/internal/dynamo-browse/models/query.go new file mode 100644 index 0000000..cecde72 --- /dev/null +++ b/internal/dynamo-browse/models/query.go @@ -0,0 +1,8 @@ +package models + +import "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + +type QueryExecutionPlan struct { + CanQuery bool + Expression expression.Expression +} diff --git a/internal/dynamo-browse/models/queryexpr/ast.go b/internal/dynamo-browse/models/queryexpr/ast.go new file mode 100644 index 0000000..4750028 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/ast.go @@ -0,0 +1,32 @@ +package queryexpr + +import ( + "github.com/alecthomas/participle/v2" + "github.com/pkg/errors" +) + +type astExpr struct { + Equality *astBinOp `parser:"@@"` +} + +type astBinOp struct { + Name string `parser:"@Ident"` + Op string `parser:"@('^' '=' | '=')"` + Value *astLiteralValue `parser:"@@"` +} + +type astLiteralValue struct { + String string `parser:"@String"` +} + +var parser = participle.MustBuild(&astExpr{}) + +func Parse(expr string) (*QueryExpr, error) { + var ast astExpr + + if err := parser.ParseString("expr", expr, &ast); err != nil { + return nil, errors.Wrapf(err, "cannot parse expression: '%v'", expr) + } + + return &QueryExpr{ast: &ast}, nil +} diff --git a/internal/dynamo-browse/models/queryexpr/astquery.go b/internal/dynamo-browse/models/queryexpr/astquery.go new file mode 100644 index 0000000..c2e2dbe --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/astquery.go @@ -0,0 +1,52 @@ +package queryexpr + +import ( + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/pkg/errors" +) + +func (a *astExpr) calcQuery(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { + return a.Equality.calcQuery(tableInfo) +} + +func (a *astBinOp) calcQuery(info *models.TableInfo) (*models.QueryExecutionPlan, error) { + // TODO: check if can be a query + cb, err := a.calcQueryForScan(info) + if err != nil { + return nil, err + } + + builder := expression.NewBuilder() + builder = builder.WithFilter(cb) + + expr, err := builder.Build() + if err != nil { + return nil, err + } + + return &models.QueryExecutionPlan{ + CanQuery: false, + Expression: expr, + }, nil +} + +func (a *astBinOp) calcQueryForScan(info *models.TableInfo) (expression.ConditionBuilder, error) { + v, err := a.Value.goValue() + if err != nil { + return expression.ConditionBuilder{}, err + } + + switch a.Op { + case "=": + return expression.Name(a.Name).Equal(expression.Value(v)), nil + case "^=": + strValue, isStrValue := v.(string) + if !isStrValue { + return expression.ConditionBuilder{}, errors.New("operand '^=' must be string") + } + return expression.Name(a.Name).BeginsWith(strValue), nil + } + + return expression.ConditionBuilder{}, errors.Errorf("unrecognised operator: %v", a.Op) +} diff --git a/internal/dynamo-browse/models/queryexpr/expr.go b/internal/dynamo-browse/models/queryexpr/expr.go new file mode 100644 index 0000000..128e1df --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/expr.go @@ -0,0 +1,11 @@ +package queryexpr + +import "github.com/lmika/awstools/internal/dynamo-browse/models" + +type QueryExpr struct { + ast *astExpr +} + +func (md *QueryExpr) BuildQuery(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { + return md.ast.calcQuery(tableInfo) +} diff --git a/internal/dynamo-browse/models/queryexpr/expr_test.go b/internal/dynamo-browse/models/queryexpr/expr_test.go new file mode 100644 index 0000000..21c3f3b --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/expr_test.go @@ -0,0 +1,47 @@ +package queryexpr_test + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" + "testing" + + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/stretchr/testify/assert" +) + +func TestModExpr_Query(t *testing.T) { + tableInfo := &models.TableInfo{ + Name: "test", + Keys: models.KeyAttribute{ + PartitionKey: "pk", + SortKey: "sk", + }, + } + + t.Run("perform query when request pk is fixed", func(t *testing.T) { + modExpr, err := queryexpr.Parse(`pk="prefix"`) + assert.NoError(t, err) + + plan, err := modExpr.BuildQuery(tableInfo) + assert.NoError(t, err) + + assert.False(t, plan.CanQuery) + assert.Equal(t, "#0 = :0", aws.ToString(plan.Expression.Filter())) + assert.Equal(t, "pk", plan.Expression.Names()["#0"]) + assert.Equal(t, "prefix", plan.Expression.Values()[":0"].(*types.AttributeValueMemberS).Value) + }) + + t.Run("perform scan when request pk prefix", func(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) + assert.NoError(t, err) + + assert.False(t, plan.CanQuery) + assert.Equal(t, "begins_with (#0, :0)", aws.ToString(plan.Expression.Filter())) + assert.Equal(t, "pk", plan.Expression.Names()["#0"]) + assert.Equal(t, "prefix", plan.Expression.Values()[":0"].(*types.AttributeValueMemberS).Value) + }) +} diff --git a/internal/dynamo-browse/models/queryexpr/values.go b/internal/dynamo-browse/models/queryexpr/values.go new file mode 100644 index 0000000..d6b3739 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/values.go @@ -0,0 +1,24 @@ +package queryexpr + +import ( + "strconv" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/pkg/errors" +) + +func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { + s, err := strconv.Unquote(a.String) + if err != nil { + return nil, errors.Wrap(err, "cannot unquote string") + } + return &types.AttributeValueMemberS{Value: s}, nil +} + +func (a *astLiteralValue) goValue() (any, error) { + s, err := strconv.Unquote(a.String) + if err != nil { + return nil, errors.Wrap(err, "cannot unquote string") + } + return s, nil +} diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go index 1e46e72..e05cd5f 100644 --- a/internal/dynamo-browse/providers/dynamo/provider.go +++ b/internal/dynamo-browse/providers/dynamo/provider.go @@ -2,8 +2,8 @@ package dynamo import ( "context" - "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/models" @@ -64,11 +64,18 @@ func NewProvider(client *dynamodb.Client) *Provider { return &Provider{client: client} } -func (p *Provider) ScanItems(ctx context.Context, tableName string, maxItems int) ([]models.Item, error) { - paginator := dynamodb.NewScanPaginator(p.client, &dynamodb.ScanInput{ +func (p *Provider) ScanItems(ctx context.Context, tableName string, filterExpr *expression.Expression, maxItems int) ([]models.Item, error) { + input := &dynamodb.ScanInput{ TableName: aws.String(tableName), Limit: aws.Int32(int32(maxItems)), - }) + } + if filterExpr != nil { + input.FilterExpression = filterExpr.Filter() + input.ExpressionAttributeNames = filterExpr.Names() + input.ExpressionAttributeValues = filterExpr.Values() + } + + paginator := dynamodb.NewScanPaginator(p.client, input) items := make([]models.Item, 0) diff --git a/internal/dynamo-browse/services/tables/iface.go b/internal/dynamo-browse/services/tables/iface.go index 8548115..58d63ec 100644 --- a/internal/dynamo-browse/services/tables/iface.go +++ b/internal/dynamo-browse/services/tables/iface.go @@ -2,6 +2,7 @@ package tables import ( "context" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/awstools/internal/dynamo-browse/models" @@ -10,7 +11,7 @@ import ( type TableProvider interface { ListTables(ctx context.Context) ([]string, error) DescribeTable(ctx context.Context, tableName string) (*models.TableInfo, error) - ScanItems(ctx context.Context, tableName string, maxItems int) ([]models.Item, error) + ScanItems(ctx context.Context, tableName string, filterExpr *expression.Expression, maxItems int) ([]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 } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index f6b56e5..d58aaad 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -2,6 +2,8 @@ package tables import ( "context" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" "sort" "strings" @@ -28,7 +30,11 @@ func (s *Service) Describe(ctx context.Context, table string) (*models.TableInfo } func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) { - results, err := s.provider.ScanItems(ctx, tableInfo.Name, 1000) + return s.doScan(ctx, tableInfo, nil) +} + +func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, filterExpr *expression.Expression) (*models.ResultSet, error) { + results, err := s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, 1000) if err != nil { return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) } @@ -101,6 +107,25 @@ func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items return nil } +func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) { + expr, err := queryexpr.Parse(queryExpr) + if err != nil { + return nil, err + } + + plan, err := expr.BuildQuery(tableInfo) + if err != nil { + return nil, err + } + + // TEMP + if plan.CanQuery { + return nil, errors.Errorf("queries not yet supported") + } + + return s.doScan(ctx, tableInfo, &plan.Expression) +} + // TODO: move into a new service func (s *Service) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet { for i, item := range resultSet.Items() { diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 2e9d9ba..b04a327 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -98,8 +98,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if idx := m.tableView.SelectedItemIndex(); idx >= 0 { return m, m.tableWriteController.ToggleMark(idx) } - case "r": + case "R": return m, m.tableReadController.Rescan() + case "?": + return m, m.tableReadController.PromptForQuery() case "/": return m, m.tableReadController.Filter() case ":": From 809f9adfea5ae4d81ab503f62ae0c397901337f7 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 22 Jun 2022 11:57:12 +1000 Subject: [PATCH 23/26] Added mode line Also rescanning will maintain the current query --- internal/common/ui/events/commands.go | 5 +++ internal/common/ui/events/errors.go | 3 ++ internal/dynamo-browse/controllers/events.go | 29 +++++++++++++- internal/dynamo-browse/controllers/iface.go | 2 +- internal/dynamo-browse/controllers/state.go | 16 ++++++++ .../dynamo-browse/controllers/tableread.go | 37 ++++++++---------- .../dynamo-browse/controllers/tablewrite.go | 6 +-- internal/dynamo-browse/models/models.go | 6 +++ .../dynamo-browse/models/queryexpr/ast.go | 2 +- .../queryexpr/{astquery.go => calcquery.go} | 0 .../dynamo-browse/models/queryexpr/expr.go | 6 ++- .../dynamo-browse/models/queryexpr/tostr.go | 13 +++++++ .../dynamo-browse/models/queryexpr/values.go | 4 +- .../dynamo-browse/services/tables/service.go | 39 ++++++++++--------- internal/dynamo-browse/ui/model.go | 11 ++++-- .../ui/teamodels/dynamoitemview/model.go | 5 ++- .../ui/teamodels/dynamotableview/model.go | 5 ++- .../dynamo-browse/ui/teamodels/frame/frame.go | 21 ++++++---- .../ui/teamodels/statusandprompt/model.go | 25 ++++++++++-- .../ui/teamodels/styles/styles.go | 29 ++++++++++++++ .../ui/teamodels/tableselect/model.go | 5 ++- 21 files changed, 197 insertions(+), 72 deletions(-) rename internal/dynamo-browse/models/queryexpr/{astquery.go => calcquery.go} (100%) create mode 100644 internal/dynamo-browse/models/queryexpr/tostr.go create mode 100644 internal/dynamo-browse/ui/teamodels/styles/styles.go diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index 19857b4..ef82c2f 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -43,3 +43,8 @@ func Confirm(prompt string, onYes func() tea.Cmd) tea.Cmd { type MessageWithStatus interface { StatusMessage() string } + +type MessageWithMode interface { + MessageWithStatus + ModeMessage() string +} diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go index 9688142..9aa3388 100644 --- a/internal/common/ui/events/errors.go +++ b/internal/common/ui/events/errors.go @@ -10,6 +10,9 @@ type ErrorMsg error // Message indicates that a message should be shown to the user type StatusMsg string +// ModeMessage indicates that the mode should be changed to the following +type ModeMessage string + // PromptForInput indicates that the context is requesting a line of input type PromptForInputMsg struct { Prompt string diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index a460f5d..c8a3713 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -1,18 +1,43 @@ package controllers import ( + "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type NewResultSet struct { ResultSet *models.ResultSet + currentFilter string + filteredCount int statusMessage string } +func (rs NewResultSet) ModeMessage() string { + var modeLine string + + if rs.ResultSet.Query != nil { + modeLine = rs.ResultSet.Query.String() + } else { + modeLine = "All results" + } + + if rs.currentFilter != "" { + modeLine = fmt.Sprintf("%v - Filter: '%v'", modeLine, rs.currentFilter) + } + return modeLine +} + func (rs NewResultSet) StatusMessage() string { - //return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) - return rs.statusMessage + if rs.statusMessage != "" { + return rs.statusMessage + } + + if rs.currentFilter != "" { + return fmt.Sprintf("%d of %d items returned", rs.filteredCount, len(rs.ResultSet.Items())) + } else { + return fmt.Sprintf("%d items returned", len(rs.ResultSet.Items())) + } } type SetReadWrite struct { diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go index fcd4af1..bf61064 100644 --- a/internal/dynamo-browse/controllers/iface.go +++ b/internal/dynamo-browse/controllers/iface.go @@ -10,5 +10,5 @@ type TableReadService interface { Describe(ctx context.Context, table string) (*models.TableInfo, error) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) Filter(resultSet *models.ResultSet, filter string) *models.ResultSet - ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) + ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, query models.Queryable) (*models.ResultSet, error) } diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index 3518e6b..f94893c 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -44,3 +44,19 @@ func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string s.resultSet = resultSet s.filter = filter } + +func (s *State) buildNewResultSetMessage(statusMessage string) NewResultSet { + s.mutex.Lock() + defer s.mutex.Unlock() + + var filteredCount int = 0 + if s.filter != "" { + for i := range s.resultSet.Items() { + if !s.resultSet.Hidden(i) { + filteredCount += 1 + } + } + } + + return NewResultSet{s.resultSet, s.filter, filteredCount, statusMessage} +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index c07d208..92dbc33 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -3,10 +3,10 @@ package controllers import ( "context" "encoding/csv" - "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" "github.com/pkg/errors" "os" "sync" @@ -81,12 +81,20 @@ func (c *TableReadController) PromptForQuery() tea.Cmd { Prompt: "query: ", OnDone: func(value string) tea.Cmd { if value == "" { - return c.Rescan() + return func() tea.Msg { + resultSet := c.state.ResultSet() + return c.doScan(context.Background(), resultSet, nil) + } + } + + expr, err := queryexpr.Parse(value) + if err != nil { + return events.SetError(err) } return func() tea.Msg { resultSet := c.state.ResultSet() - newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, value) + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, expr) if err != nil { return events.Error(err) } @@ -100,7 +108,8 @@ func (c *TableReadController) PromptForQuery() tea.Cmd { func (c *TableReadController) Rescan() tea.Cmd { return func() tea.Msg { - return c.doScan(context.Background(), c.state.ResultSet()) + resultSet := c.state.ResultSet() + return c.doScan(context.Background(), resultSet, resultSet.Query) } } @@ -139,8 +148,8 @@ func (c *TableReadController) ExportCSV(filename string) tea.Cmd { } } -func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet) tea.Msg { - newResultSet, err := c.tableService.Scan(ctx, resultSet.TableInfo) +func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable) tea.Msg { + newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) if err != nil { return events.Error(err) } @@ -152,21 +161,7 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { c.state.setResultSetAndFilter(resultSet, filter) - - var statusMessage string - if filter != "" { - var filteredCount int - for i := range resultSet.Items() { - if !resultSet.Hidden(i) { - filteredCount += 1 - } - } - statusMessage = fmt.Sprintf("%d of %d items returned", filteredCount, len(resultSet.Items())) - } else { - statusMessage = fmt.Sprintf("%d items returned", len(resultSet.Items())) - } - - return NewResultSet{resultSet, statusMessage} + return c.state.buildNewResultSetMessage("") } func (c *TableReadController) Unmark() tea.Cmd { diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 60ea800..c155e80 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -61,7 +61,7 @@ func (twc *TableWriteController) NewItem() tea.Cmd { Dirty: true, }) }) - return NewResultSet{twc.state.ResultSet(), "New item added"} + return twc.state.buildNewResultSetMessage("New item added") } return keyPrompts.next() @@ -161,7 +161,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query) } }, } @@ -190,7 +190,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Cmd { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query) } }, } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 40e4f31..0043691 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -2,11 +2,17 @@ package models type ResultSet struct { TableInfo *TableInfo + Query Queryable Columns []string items []Item attributes []ItemAttribute } +type Queryable interface { + String() string + Plan(tableInfo *TableInfo) (*QueryExecutionPlan, error) +} + type ItemAttribute struct { Marked bool Hidden bool diff --git a/internal/dynamo-browse/models/queryexpr/ast.go b/internal/dynamo-browse/models/queryexpr/ast.go index 4750028..d8c9912 100644 --- a/internal/dynamo-browse/models/queryexpr/ast.go +++ b/internal/dynamo-browse/models/queryexpr/ast.go @@ -16,7 +16,7 @@ type astBinOp struct { } type astLiteralValue struct { - String string `parser:"@String"` + StringVal string `parser:"@String"` } var parser = participle.MustBuild(&astExpr{}) diff --git a/internal/dynamo-browse/models/queryexpr/astquery.go b/internal/dynamo-browse/models/queryexpr/calcquery.go similarity index 100% rename from internal/dynamo-browse/models/queryexpr/astquery.go rename to internal/dynamo-browse/models/queryexpr/calcquery.go diff --git a/internal/dynamo-browse/models/queryexpr/expr.go b/internal/dynamo-browse/models/queryexpr/expr.go index 128e1df..2916c15 100644 --- a/internal/dynamo-browse/models/queryexpr/expr.go +++ b/internal/dynamo-browse/models/queryexpr/expr.go @@ -6,6 +6,10 @@ type QueryExpr struct { ast *astExpr } -func (md *QueryExpr) BuildQuery(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { +func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { return md.ast.calcQuery(tableInfo) } + +func (md *QueryExpr) String() string { + return md.ast.String() +} diff --git a/internal/dynamo-browse/models/queryexpr/tostr.go b/internal/dynamo-browse/models/queryexpr/tostr.go new file mode 100644 index 0000000..9453954 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/tostr.go @@ -0,0 +1,13 @@ +package queryexpr + +func (a *astExpr) String() string { + return a.Equality.String() +} + +func (a *astBinOp) String() string { + return a.Name + a.Op + a.Value.String() +} + +func (a *astLiteralValue) String() string { + return a.StringVal +} diff --git a/internal/dynamo-browse/models/queryexpr/values.go b/internal/dynamo-browse/models/queryexpr/values.go index d6b3739..8bb0e81 100644 --- a/internal/dynamo-browse/models/queryexpr/values.go +++ b/internal/dynamo-browse/models/queryexpr/values.go @@ -8,7 +8,7 @@ import ( ) func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { - s, err := strconv.Unquote(a.String) + s, err := strconv.Unquote(a.StringVal) if err != nil { return nil, errors.Wrap(err, "cannot unquote string") } @@ -16,7 +16,7 @@ func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { } func (a *astLiteralValue) goValue() (any, error) { - s, err := strconv.Unquote(a.String) + s, err := strconv.Unquote(a.StringVal) if err != nil { return nil, errors.Wrap(err, "cannot unquote string") } diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index d58aaad..50f02eb 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -3,7 +3,6 @@ package tables import ( "context" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" - "github.com/lmika/awstools/internal/dynamo-browse/models/queryexpr" "sort" "strings" @@ -33,7 +32,23 @@ func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*model return s.doScan(ctx, tableInfo, nil) } -func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, filterExpr *expression.Expression) (*models.ResultSet, error) { +func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) { + var filterExpr *expression.Expression + + if expr != nil { + plan, err := expr.Plan(tableInfo) + if err != nil { + return nil, err + } + + // TEMP + if plan.CanQuery { + return nil, errors.Errorf("queries not yet supported") + } + + filterExpr = &plan.Expression + } + results, err := s.provider.ScanItems(ctx, tableInfo.Name, filterExpr, 1000) if err != nil { return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) @@ -76,6 +91,7 @@ func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, filte resultSet := &models.ResultSet{ TableInfo: tableInfo, + Query: expr, Columns: columns, } resultSet.SetItems(results) @@ -107,23 +123,8 @@ func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, items return nil } -func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, queryExpr string) (*models.ResultSet, error) { - expr, err := queryexpr.Parse(queryExpr) - if err != nil { - return nil, err - } - - plan, err := expr.BuildQuery(tableInfo) - if err != nil { - return nil, err - } - - // TEMP - if plan.CanQuery { - return nil, errors.Errorf("queries not yet supported") - } - - return s.doScan(ctx, tableInfo, &plan.Expression) +func (s *Service) ScanOrQuery(ctx context.Context, tableInfo *models.TableInfo, expr models.Queryable) (*models.ResultSet, error) { + return s.doScan(ctx, tableInfo, expr) } // TODO: move into a new service diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index b04a327..e16ed4e 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -9,6 +9,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamotableview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/pkg/errors" ) @@ -25,10 +26,12 @@ type Model struct { } func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteController, cc *commandctrl.CommandController) Model { - dtv := dynamotableview.New() - div := dynamoitemview.New() - statusAndPrompt := statusandprompt.New(layout.NewVBox(layout.LastChildFixedAt(17), dtv, div), "") - tableSelect := tableselect.New(statusAndPrompt) + uiStyles := styles.DefaultStyles + + dtv := dynamotableview.New(uiStyles) + div := dynamoitemview.New(uiStyles) + statusAndPrompt := statusandprompt.New(layout.NewVBox(layout.LastChildFixedAt(17), dtv, div), "", uiStyles.StatusAndPrompt) + tableSelect := tableselect.New(statusAndPrompt, uiStyles) cc.AddCommands(&commandctrl.CommandContext{ Commands: map[string]commandctrl.Command{ diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index bcc65cf..1ac4440 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -3,6 +3,7 @@ package dynamoitemview import ( "fmt" "github.com/lmika/awstools/internal/dynamo-browse/models/itemrender" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "io" "strings" "text/tabwriter" @@ -38,9 +39,9 @@ type Model struct { selectedItem models.Item } -func New() *Model { +func New(uiStyles styles.Styles) *Model { return &Model{ - frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), + frameTitle: frame.NewFrameTitle("Item", false, uiStyles.Frames), viewport: viewport.New(100, 100), } } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index fbdfdb4..1e9f187 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -9,6 +9,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" table "github.com/lmika/go-bubble-table" ) @@ -54,12 +55,12 @@ func (cm columnModel) Header(index int) string { return cm.m.resultSet.Columns[cm.m.colOffset+index] } -func New() *Model { +func New(uiStyles styles.Styles) *Model { tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100) rows := make([]table.Row, 0) tbl.SetRows(rows) - frameTitle := frame.NewFrameTitle("No table", true, activeHeaderStyle) + frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames) return &Model{ frameTitle: frameTitle, diff --git a/internal/dynamo-browse/ui/teamodels/frame/frame.go b/internal/dynamo-browse/ui/teamodels/frame/frame.go index 7ce9ba6..e2da798 100644 --- a/internal/dynamo-browse/ui/teamodels/frame/frame.go +++ b/internal/dynamo-browse/ui/teamodels/frame/frame.go @@ -15,14 +15,19 @@ var ( // Frame is a frame that appears in the type FrameTitle struct { - header string - active bool - activeStyle lipgloss.Style - width int + header string + active bool + style Style + width int } -func NewFrameTitle(header string, active bool, activeStyle lipgloss.Style) FrameTitle { - return FrameTitle{header, active, activeStyle, 0} +type Style struct { + ActiveTitle lipgloss.Style + InactiveTitle lipgloss.Style +} + +func NewFrameTitle(header string, active bool, style Style) FrameTitle { + return FrameTitle{header, active, style, 0} } func (f *FrameTitle) SetTitle(title string) { @@ -42,9 +47,9 @@ func (f FrameTitle) HeaderHeight() int { } func (f FrameTitle) headerView() string { - style := inactiveHeaderStyle + style := f.style.InactiveTitle if f.active { - style = f.activeStyle + style = f.style.ActiveTitle } titleText := f.header diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index 380896b..a290750 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -12,15 +12,21 @@ import ( // event is received, focus will be torn away and the user will be given a prompt the enter text. type StatusAndPrompt struct { model layout.ResizingModel + style Style + modeLine string statusMessage string pendingInput *events.PromptForInputMsg textInput textinput.Model width int } -func New(model layout.ResizingModel, initialMsg string) *StatusAndPrompt { +type Style struct { + ModeLine lipgloss.Style +} + +func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt { textInput := textinput.New() - return &StatusAndPrompt{model: model, statusMessage: initialMsg, textInput: textInput} + return &StatusAndPrompt{model: model, style: style, statusMessage: initialMsg, modeLine: "", textInput: textInput} } func (s *StatusAndPrompt) Init() tea.Cmd { @@ -33,7 +39,12 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.statusMessage = "Error: " + msg.Error() case events.StatusMsg: s.statusMessage = string(msg) + case events.ModeMessage: + s.modeLine = string(msg) case events.MessageWithStatus: + if hasModeMessage, ok := msg.(events.MessageWithMode); ok { + s.modeLine = hasModeMessage.ModeMessage() + } s.statusMessage = msg.StatusMessage() case events.PromptForInputMsg: if s.pendingInput != nil { @@ -87,8 +98,14 @@ func (s *StatusAndPrompt) Resize(w, h int) layout.ResizingModel { } func (s *StatusAndPrompt) viewStatus() string { + modeLine := s.style.ModeLine.Render(lipgloss.PlaceHorizontal(s.width, lipgloss.Left, s.modeLine, lipgloss.WithWhitespaceChars(" "))) + + var statusLine string if s.pendingInput != nil { - return s.textInput.View() + statusLine = s.textInput.View() + } else { + statusLine = s.statusMessage } - return s.statusMessage + + return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine) } diff --git a/internal/dynamo-browse/ui/teamodels/styles/styles.go b/internal/dynamo-browse/ui/teamodels/styles/styles.go new file mode 100644 index 0000000..10caf45 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#4479ff")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/dynamo-browse/ui/teamodels/tableselect/model.go b/internal/dynamo-browse/ui/teamodels/tableselect/model.go index 1feebe7..8f06a97 100644 --- a/internal/dynamo-browse/ui/teamodels/tableselect/model.go +++ b/internal/dynamo-browse/ui/teamodels/tableselect/model.go @@ -7,6 +7,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/controllers" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/utils" ) @@ -26,8 +27,8 @@ type Model struct { w, h int } -func New(submodel tea.Model) *Model { - frameTitle := frame.NewFrameTitle("Select table", false, activeHeaderStyle) +func New(submodel tea.Model, uiStyles styles.Styles) *Model { + frameTitle := frame.NewFrameTitle("Select table", false, uiStyles.Frames) return &Model{frameTitle: frameTitle, submodel: submodel} } From eadf8d172042eb8ec9eac021beabac28d8c5c37f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 27 Jun 2022 16:05:59 +1000 Subject: [PATCH 24/26] Fixed styling of the other tools --- cmd/slog-view/main.go | 5 +- cmd/ssm-browse/main.go | 6 +- .../dynamo-browse/controllers/tableread.go | 4 +- .../dynamo-browse/controllers/tablewrite.go | 1 + internal/dynamo-browse/models/models.go | 53 ++++++++++++++- .../dynamo-browse/services/tables/service.go | 66 +++++++++---------- .../ui/teamodels/dynamoitemview/model.go | 12 +++- .../ui/teamodels/dynamotableview/model.go | 8 +-- .../ui/teamodels/dynamotableview/tblmodel.go | 2 +- internal/slog-view/styles/styles.go | 29 ++++++++ .../slog-view/ui/fullviewlinedetails/model.go | 5 +- internal/slog-view/ui/linedetails/model.go | 11 +--- internal/slog-view/ui/loglines/model.go | 11 +--- internal/slog-view/ui/model.go | 34 +++++----- internal/sqs-browse/styles/styles.go | 29 ++++++++ internal/ssm-browse/styles/styles.go | 29 ++++++++ internal/ssm-browse/ui/model.go | 9 +-- internal/ssm-browse/ui/ssmdetails/model.go | 11 +--- internal/ssm-browse/ui/ssmlist/ssmlist.go | 11 +--- 19 files changed, 231 insertions(+), 105 deletions(-) create mode 100644 internal/slog-view/styles/styles.go create mode 100644 internal/sqs-browse/styles/styles.go create mode 100644 internal/ssm-browse/styles/styles.go diff --git a/cmd/slog-view/main.go b/cmd/slog-view/main.go index f577a07..5b49b79 100644 --- a/cmd/slog-view/main.go +++ b/cmd/slog-view/main.go @@ -7,14 +7,15 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/lmika/awstools/internal/common/ui/commandctrl" "github.com/lmika/awstools/internal/common/ui/logging" - "github.com/lmika/awstools/internal/slog-view/services/logreader" "github.com/lmika/awstools/internal/slog-view/controllers" + "github.com/lmika/awstools/internal/slog-view/services/logreader" "github.com/lmika/awstools/internal/slog-view/ui" "github.com/lmika/gopkgs/cli" "os" ) func main() { + var flagDebug = flag.String("debug", "", "file to log debug messages") flag.Parse() if flag.NArg() == 0 { @@ -24,7 +25,7 @@ func main() { // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. lipgloss.HasDarkBackground() - closeFn := logging.EnableLogging() + closeFn := logging.EnableLogging(*flagDebug) defer closeFn() service := logreader.NewService() diff --git a/cmd/ssm-browse/main.go b/cmd/ssm-browse/main.go index 6b215e9..6d1e275 100644 --- a/cmd/ssm-browse/main.go +++ b/cmd/ssm-browse/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "fmt" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ssm" @@ -18,10 +19,13 @@ import ( ) func main() { + var flagDebug = flag.String("debug", "", "file to log debug messages") + flag.Parse() + // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. lipgloss.HasDarkBackground() - closeFn := logging.EnableLogging() + closeFn := logging.EnableLogging(*flagDebug) defer closeFn() cfg, err := config.LoadDefaultConfig(context.Background()) diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 92dbc33..a6e426e 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -129,12 +129,12 @@ func (c *TableReadController) ExportCSV(filename string) tea.Cmd { cw := csv.NewWriter(f) defer cw.Flush() - columns := resultSet.Columns + columns := resultSet.Columns() if err := cw.Write(columns); err != nil { return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) } - row := make([]string, len(resultSet.Columns)) + row := make([]string, len(columns)) for _, item := range resultSet.Items() { for i, col := range columns { row[i], _ = item.AttributeValueAsString(col) diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index c155e80..57c54e1 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -77,6 +77,7 @@ func (twc *TableWriteController) SetStringValue(idx int, key string) tea.Cmd { twc.state.withResultSet(func(set *models.ResultSet) { set.Items()[idx][key] = &types.AttributeValueMemberS{Value: value} set.SetDirty(idx, true) + set.RefreshColumns() }) return ResultSetUpdated{} } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 0043691..ab82aa0 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -1,11 +1,15 @@ package models +import "sort" + type ResultSet struct { - TableInfo *TableInfo - Query Queryable - Columns []string + TableInfo *TableInfo + Query Queryable + //Columns []string items []Item attributes []ItemAttribute + + columns []string } type Queryable interface { @@ -75,3 +79,46 @@ func (rs *ResultSet) MarkedItems() []Item { } return items } + +func (rs *ResultSet) Columns() []string { + if rs.columns == nil { + rs.RefreshColumns() + } + return rs.columns +} + +func (rs *ResultSet) RefreshColumns() { + seenColumns := make(map[string]int) + seenColumns[rs.TableInfo.Keys.PartitionKey] = 0 + if rs.TableInfo.Keys.SortKey != "" { + seenColumns[rs.TableInfo.Keys.SortKey] = 1 + } + + for _, definedAttribute := range rs.TableInfo.DefinedAttributes { + if _, seen := seenColumns[definedAttribute]; !seen { + seenColumns[definedAttribute] = len(seenColumns) + } + } + + otherColsRank := len(seenColumns) + for _, result := range rs.items { + for k := range result { + if _, isSeen := seenColumns[k]; !isSeen { + seenColumns[k] = otherColsRank + } + } + } + + columns := make([]string, 0, len(seenColumns)) + for k := range seenColumns { + columns = append(columns, k) + } + sort.Slice(columns, func(i, j int) bool { + if seenColumns[columns[i]] == seenColumns[columns[j]] { + return columns[i] < columns[j] + } + return seenColumns[columns[i]] < seenColumns[columns[j]] + }) + + rs.columns = columns +} diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go index 50f02eb..d695c08 100644 --- a/internal/dynamo-browse/services/tables/service.go +++ b/internal/dynamo-browse/services/tables/service.go @@ -3,7 +3,6 @@ package tables import ( "context" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" - "sort" "strings" "github.com/lmika/awstools/internal/dynamo-browse/models" @@ -55,46 +54,47 @@ func (s *Service) doScan(ctx context.Context, tableInfo *models.TableInfo, expr } // Get the columns - seenColumns := make(map[string]int) - seenColumns[tableInfo.Keys.PartitionKey] = 0 - if tableInfo.Keys.SortKey != "" { - seenColumns[tableInfo.Keys.SortKey] = 1 - } - - for _, definedAttribute := range tableInfo.DefinedAttributes { - if _, seen := seenColumns[definedAttribute]; !seen { - seenColumns[definedAttribute] = len(seenColumns) - } - } - - otherColsRank := len(seenColumns) - for _, result := range results { - for k := range result { - if _, isSeen := seenColumns[k]; !isSeen { - seenColumns[k] = otherColsRank - } - } - } - - columns := make([]string, 0, len(seenColumns)) - for k := range seenColumns { - columns = append(columns, k) - } - sort.Slice(columns, func(i, j int) bool { - if seenColumns[columns[i]] == seenColumns[columns[j]] { - return columns[i] < columns[j] - } - return seenColumns[columns[i]] < seenColumns[columns[j]] - }) + //seenColumns := make(map[string]int) + //seenColumns[tableInfo.Keys.PartitionKey] = 0 + //if tableInfo.Keys.SortKey != "" { + // seenColumns[tableInfo.Keys.SortKey] = 1 + //} + // + //for _, definedAttribute := range tableInfo.DefinedAttributes { + // if _, seen := seenColumns[definedAttribute]; !seen { + // seenColumns[definedAttribute] = len(seenColumns) + // } + //} + // + //otherColsRank := len(seenColumns) + //for _, result := range results { + // for k := range result { + // if _, isSeen := seenColumns[k]; !isSeen { + // seenColumns[k] = otherColsRank + // } + // } + //} + // + //columns := make([]string, 0, len(seenColumns)) + //for k := range seenColumns { + // columns = append(columns, k) + //} + //sort.Slice(columns, func(i, j int) bool { + // if seenColumns[columns[i]] == seenColumns[columns[j]] { + // return columns[i] < columns[j] + // } + // return seenColumns[columns[i]] < seenColumns[columns[j]] + //}) models.Sort(results, tableInfo) resultSet := &models.ResultSet{ TableInfo: tableInfo, Query: expr, - Columns: columns, + //Columns: columns, } resultSet.SetItems(results) + resultSet.RefreshColumns() return resultSet, nil } diff --git a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go index 1ac4440..f921656 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamoitemview/model.go @@ -89,11 +89,21 @@ func (m *Model) updateViewportToSelectedMessage() { viewportContent := &strings.Builder{} tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0) - for _, colName := range m.currentResultSet.Columns { + + seenColumns := make(map[string]struct{}) + for _, colName := range m.currentResultSet.Columns() { + seenColumns[colName] = struct{}{} if r := m.selectedItem.Renderer(colName); r != nil { m.renderItem(tabWriter, "", colName, r) } } + for k, _ := range m.selectedItem { + if _, seen := seenColumns[k]; !seen { + if r := m.selectedItem.Renderer(k); r != nil { + m.renderItem(tabWriter, "", k, r) + } + } + } tabWriter.Flush() m.viewport.Width = m.w diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 1e9f187..b86fa81 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -48,11 +48,11 @@ type columnModel struct { } func (cm columnModel) Len() int { - return len(cm.m.resultSet.Columns[cm.m.colOffset:]) + return len(cm.m.resultSet.Columns()[cm.m.colOffset:]) } func (cm columnModel) Header(index int) string { - return cm.m.resultSet.Columns[cm.m.colOffset+index] + return cm.m.resultSet.Columns()[cm.m.colOffset+index] } func New(uiStyles styles.Styles) *Model { @@ -124,8 +124,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) setLeftmostDisplayedColumn(newCol int) { if newCol < 0 { m.colOffset = 0 - } else if newCol >= len(m.resultSet.Columns) { - m.colOffset = len(m.resultSet.Columns) - 1 + } else if newCol >= len(m.resultSet.Columns()) { + m.colOffset = len(m.resultSet.Columns()) - 1 } else { m.colOffset = newCol } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index 2ecfafc..0bbd24d 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -50,7 +50,7 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { metaInfoStyle := style.Copy().Inherit(metaInfoStyle) sb := strings.Builder{} - for i, colName := range mtr.resultSet.Columns[mtr.model.colOffset:] { + for i, colName := range mtr.resultSet.Columns()[mtr.model.colOffset:] { if i > 0 { sb.WriteString(style.Render("\t")) } diff --git a/internal/slog-view/styles/styles.go b/internal/slog-view/styles/styles.go new file mode 100644 index 0000000..9eabde2 --- /dev/null +++ b/internal/slog-view/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/slog-view/ui/fullviewlinedetails/model.go b/internal/slog-view/ui/fullviewlinedetails/model.go index 778841e..906db1b 100644 --- a/internal/slog-view/ui/fullviewlinedetails/model.go +++ b/internal/slog-view/ui/fullviewlinedetails/model.go @@ -2,6 +2,7 @@ package fullviewlinedetails import ( tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/slog-view/models" "github.com/lmika/awstools/internal/slog-view/ui/linedetails" @@ -14,10 +15,10 @@ type Model struct { visible bool } -func NewModel(submodel tea.Model) *Model { +func NewModel(submodel tea.Model, style frame.Style) *Model { return &Model{ submodel: submodel, - lineDetails: linedetails.New(), + lineDetails: linedetails.New(style), } } diff --git a/internal/slog-view/ui/linedetails/model.go b/internal/slog-view/ui/linedetails/model.go index 8378e03..b5712f5 100644 --- a/internal/slog-view/ui/linedetails/model.go +++ b/internal/slog-view/ui/linedetails/model.go @@ -10,13 +10,6 @@ import ( "github.com/lmika/awstools/internal/slog-view/models" ) -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#9c9c9c")) -) - type Model struct { frameTitle frame.FrameTitle viewport viewport.Model @@ -27,11 +20,11 @@ type Model struct { selectedItem *models.LogLine } -func New() *Model { +func New(style frame.Style) *Model { viewport := viewport.New(0, 0) viewport.SetContent("") return &Model{ - frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), + frameTitle: frame.NewFrameTitle("Item", false, style), viewport: viewport, } } diff --git a/internal/slog-view/ui/loglines/model.go b/internal/slog-view/ui/loglines/model.go index f682919..65287ed 100644 --- a/internal/slog-view/ui/loglines/model.go +++ b/internal/slog-view/ui/loglines/model.go @@ -10,13 +10,6 @@ import ( "path/filepath" ) -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#9c9c9c")) -) - type Model struct { frameTitle frame.FrameTitle table table.Model @@ -26,8 +19,8 @@ type Model struct { w, h int } -func New() *Model { - frameTitle := frame.NewFrameTitle("File: ", true, activeHeaderStyle) +func New(style frame.Style) *Model { + frameTitle := frame.NewFrameTitle("File: ", true, style) table := table.New(table.SimpleColumns{"level", "error", "message"}, 0, 0) return &Model{ diff --git a/internal/slog-view/ui/model.go b/internal/slog-view/ui/model.go index 41f5aea..4ef7c52 100644 --- a/internal/slog-view/ui/model.go +++ b/internal/slog-view/ui/model.go @@ -6,38 +6,40 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/awstools/internal/slog-view/controllers" + "github.com/lmika/awstools/internal/slog-view/styles" "github.com/lmika/awstools/internal/slog-view/ui/fullviewlinedetails" "github.com/lmika/awstools/internal/slog-view/ui/linedetails" "github.com/lmika/awstools/internal/slog-view/ui/loglines" ) type Model struct { - controller *controllers.LogFileController - cmdController *commandctrl.CommandController + controller *controllers.LogFileController + cmdController *commandctrl.CommandController - root tea.Model - logLines *loglines.Model - lineDetails *linedetails.Model - statusAndPrompt *statusandprompt.StatusAndPrompt + root tea.Model + logLines *loglines.Model + lineDetails *linedetails.Model + statusAndPrompt *statusandprompt.StatusAndPrompt fullViewLineDetails *fullviewlinedetails.Model } func NewModel(controller *controllers.LogFileController, cmdController *commandctrl.CommandController) Model { - logLines := loglines.New() - lineDetails := linedetails.New() + defaultStyles := styles.DefaultStyles + logLines := loglines.New(defaultStyles.Frames) + lineDetails := linedetails.New(defaultStyles.Frames) box := layout.NewVBox(layout.LastChildFixedAt(17), logLines, lineDetails) - fullViewLineDetails := fullviewlinedetails.NewModel(box) - statusAndPrompt := statusandprompt.New(fullViewLineDetails, "") + fullViewLineDetails := fullviewlinedetails.NewModel(box, defaultStyles.Frames) + statusAndPrompt := statusandprompt.New(fullViewLineDetails, "", defaultStyles.StatusAndPrompt) root := layout.FullScreen(statusAndPrompt) return Model{ - controller: controller, - cmdController: cmdController, - root: root, - statusAndPrompt: statusAndPrompt, - logLines: logLines, - lineDetails: lineDetails, + controller: controller, + cmdController: cmdController, + root: root, + statusAndPrompt: statusAndPrompt, + logLines: logLines, + lineDetails: lineDetails, fullViewLineDetails: fullViewLineDetails, } } diff --git a/internal/sqs-browse/styles/styles.go b/internal/sqs-browse/styles/styles.go new file mode 100644 index 0000000..10caf45 --- /dev/null +++ b/internal/sqs-browse/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#4479ff")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/ssm-browse/styles/styles.go b/internal/ssm-browse/styles/styles.go new file mode 100644 index 0000000..7e94086 --- /dev/null +++ b/internal/ssm-browse/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame" + "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#c144ff")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/ssm-browse/ui/model.go b/internal/ssm-browse/ui/model.go index d3c315e..00168f8 100644 --- a/internal/ssm-browse/ui/model.go +++ b/internal/ssm-browse/ui/model.go @@ -7,6 +7,7 @@ import ( "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt" "github.com/lmika/awstools/internal/ssm-browse/controllers" + "github.com/lmika/awstools/internal/ssm-browse/styles" "github.com/lmika/awstools/internal/ssm-browse/ui/ssmdetails" "github.com/lmika/awstools/internal/ssm-browse/ui/ssmlist" "github.com/pkg/errors" @@ -23,11 +24,11 @@ type Model struct { } func NewModel(controller *controllers.SSMController, cmdController *commandctrl.CommandController) Model { - ssmList := ssmlist.New() - ssmdDetails := ssmdetails.New() + defaultStyles := styles.DefaultStyles + ssmList := ssmlist.New(defaultStyles.Frames) + ssmdDetails := ssmdetails.New(defaultStyles.Frames) statusAndPrompt := statusandprompt.New( - layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails), - "") + layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails), "", defaultStyles.StatusAndPrompt) cmdController.AddCommands(&commandctrl.CommandContext{ Commands: map[string]commandctrl.Command{ diff --git a/internal/ssm-browse/ui/ssmdetails/model.go b/internal/ssm-browse/ui/ssmdetails/model.go index 7c0db0b..b4e5030 100644 --- a/internal/ssm-browse/ui/ssmdetails/model.go +++ b/internal/ssm-browse/ui/ssmdetails/model.go @@ -11,13 +11,6 @@ import ( "strings" ) -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#c144ff")) -) - type Model struct { frameTitle frame.FrameTitle viewport viewport.Model @@ -28,11 +21,11 @@ type Model struct { selectedItem *models.SSMParameter } -func New() *Model { +func New(style frame.Style) *Model { viewport := viewport.New(0, 0) viewport.SetContent("") return &Model{ - frameTitle: frame.NewFrameTitle("Item", false, activeHeaderStyle), + frameTitle: frame.NewFrameTitle("Item", false, style), viewport: viewport, } } diff --git a/internal/ssm-browse/ui/ssmlist/ssmlist.go b/internal/ssm-browse/ui/ssmlist/ssmlist.go index f909f27..1edcabc 100644 --- a/internal/ssm-browse/ui/ssmlist/ssmlist.go +++ b/internal/ssm-browse/ui/ssmlist/ssmlist.go @@ -9,13 +9,6 @@ import ( table "github.com/lmika/go-bubble-table" ) -var ( - activeHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#c144ff")) -) - type Model struct { frameTitle frame.FrameTitle table table.Model @@ -25,8 +18,8 @@ type Model struct { w, h int } -func New() *Model { - frameTitle := frame.NewFrameTitle("SSM: /", true, activeHeaderStyle) +func New(style frame.Style) *Model { + frameTitle := frame.NewFrameTitle("SSM: /", true, style) table := table.New(table.SimpleColumns{"name", "type", "value"}, 0, 0) return &Model{ From e35855f05c8a66207bfc47edd59f0727aa9d1877 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 6 Jul 2022 13:03:19 +1000 Subject: [PATCH 25/26] Added set-n command to set number attributes Also added the ability to set subattribes of maps --- .../dynamo-browse/controllers/attrpath.go | 64 +++++ internal/dynamo-browse/controllers/state.go | 7 + .../controllers/tableread_test.go | 12 +- .../dynamo-browse/controllers/tablewrite.go | 58 ++++- .../controllers/tablewrite_test.go | 243 +++++------------- .../models/queryexpr/expr_test.go | 4 +- .../providers/dynamo/provider_test.go | 10 +- .../services/tables/service_test.go | 2 +- internal/dynamo-browse/ui/model.go | 9 +- .../ui/teamodels/dialogprompt/model.go | 2 +- test/testdynamo/client.go | 6 +- 11 files changed, 223 insertions(+), 194 deletions(-) create mode 100644 internal/dynamo-browse/controllers/attrpath.go 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() {} } From c00b99a2eb5848360e47de9a2861e9172a93627b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 14 Jul 2022 21:15:31 +1000 Subject: [PATCH 26/26] dynamo-query: added delete attribute command --- .../dynamo-browse/controllers/attrpath.go | 32 +++++++++++++ .../dynamo-browse/controllers/tablewrite.go | 29 ++++++++++++ .../controllers/tablewrite_test.go | 45 +++++++++++++++++++ internal/dynamo-browse/ui/model.go | 6 +++ 4 files changed, 112 insertions(+) diff --git a/internal/dynamo-browse/controllers/attrpath.go b/internal/dynamo-browse/controllers/attrpath.go index 3a653ea..09e191f 100644 --- a/internal/dynamo-browse/controllers/attrpath.go +++ b/internal/dynamo-browse/controllers/attrpath.go @@ -31,6 +31,38 @@ func (ap attrPath) follow(item models.Item) (types.AttributeValue, error) { return step, nil } +func (ap attrPath) deleteAt(item models.Item) error { + if len(ap) == 1 { + delete(item, ap[0]) + 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: + delete(s.Value, lastSeg) + default: + return errors.Errorf("last seg expected to be a map, but was %T", lastSeg) + } + + return nil +} + func (ap attrPath) setAt(item models.Item, newValue types.AttributeValue) error { if len(ap) == 1 { item[ap[0]] = newValue diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index f6c634a..a6b2f2f 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -138,6 +138,35 @@ func (twc *TableWriteController) SetNumberValue(idx int, key string) tea.Cmd { } } +func (twc *TableWriteController) DeleteAttribute(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) + } + + if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { + err := apPath.deleteAt(set.Items()[idx]) + if err != nil { + return err + } + + set.SetDirty(idx, true) + set.RefreshColumns() + return nil + }); err != nil { + return events.Error(err) + } + + return ResultSetUpdated{} + } +} + func (twc *TableWriteController) PutItem(idx int) tea.Cmd { return func() tea.Msg { resultSet := twc.state.ResultSet() diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 094c706..98c593e 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -135,6 +135,51 @@ func TestTableWriteController_SetNumberValue(t *testing.T) { }) } +func TestTableWriteController_DeleteAttribute(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should delete top level attribute", 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)) + + invokeCommand(t, writeController.DeleteAttribute(0, "age")) + + _, hasAge := state.ResultSet().Items()[0]["age"] + assert.False(t, hasAge) + }) + + t.Run("should delete attribute of map", 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)) + + invokeCommand(t, writeController.DeleteAttribute(0, "address.no")) + + afterAddress := state.ResultSet().Items()[0]["address"].(*types.AttributeValueMemberM) + _, hasStreet := afterAddress.Value["no"] + + assert.False(t, hasStreet) + }) +} + func TestTableWriteController_PutItem(t *testing.T) { t.Run("should put the selected item if dirty", func(t *testing.T) { client, cleanupFn := testdynamo.SetupTestTable(t, testData) diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 891117f..34b62c1 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -68,6 +68,12 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon } return wc.SetNumberValue(dtv.SelectedItemIndex(), args[0]) }, + "del-attr": func(args []string) tea.Cmd { + if len(args) == 0 { + return events.SetError(errors.New("expected field")) + } + return wc.DeleteAttribute(dtv.SelectedItemIndex(), args[0]) + }, "put": func(args []string) tea.Cmd { return wc.PutItem(dtv.SelectedItemIndex())