diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 65efc80..311a719 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -22,6 +22,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/ui" "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles" + bus "github.com/lmika/events" "github.com/lmika/gopkgs/cli" "log" "net" @@ -72,6 +73,8 @@ func main() { dynamoClient = dynamodb.NewFromConfig(cfg) } + eventBus := bus.New() + uiStyles := styles.DefaultStyles dynamoProvider := dynamo.NewProvider(dynamoClient) resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) @@ -93,8 +96,10 @@ func main() { itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) state := controllers.NewState() - tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, *flagTable, true) + tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, eventBus, *flagTable) tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController, settingStore) + columnsController := controllers.NewColumnsController(eventBus) + exportController := controllers.NewExportController(state, columnsController) settingsController := controllers.NewSettingsController(settingStore) keyBindings := keybindings.Default() @@ -106,6 +111,8 @@ func main() { model := ui.NewModel( tableReadController, tableWriteController, + columnsController, + exportController, settingsController, itemRendererService, commandController, diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go index d1dca0c..e521a95 100644 --- a/internal/common/ui/events/commands.go +++ b/internal/common/ui/events/commands.go @@ -16,6 +16,12 @@ func SetStatus(msg string) tea.Cmd { } } +func SetTeaMessage(event tea.Msg) tea.Cmd { + return func() tea.Msg { + return event + } +} + func PromptForInput(prompt string, onDone func(value string) tea.Msg) tea.Msg { return PromptForInputMsg{ Prompt: prompt, diff --git a/internal/dynamo-browse/controllers/columns.go b/internal/dynamo-browse/controllers/columns.go new file mode 100644 index 0000000..75a2ed9 --- /dev/null +++ b/internal/dynamo-browse/controllers/columns.go @@ -0,0 +1,108 @@ +package controllers + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/columns" + "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" + bus "github.com/lmika/events" +) + +type ColumnsController struct { + // State + colModel *columns.Columns + resultSet *models.ResultSet +} + +func NewColumnsController(eventBus *bus.Bus) *ColumnsController { + cc := &ColumnsController{} + + eventBus.On(newResultSetEvent, cc.onNewResultSet) + return cc +} + +func (cc *ColumnsController) Columns() *columns.Columns { + return cc.colModel +} + +func (cc *ColumnsController) ToggleVisible(idx int) tea.Msg { + cc.colModel.Columns[idx].Hidden = !cc.colModel.Columns[idx].Hidden + return ColumnsUpdated{} +} + +func (cc *ColumnsController) ShiftColumnLeft(idx int) tea.Msg { + if idx == 0 { + return nil + } + + col := cc.colModel.Columns[idx-1] + cc.colModel.Columns[idx-1], cc.colModel.Columns[idx] = cc.colModel.Columns[idx], col + + return ColumnsUpdated{} +} + +func (cc *ColumnsController) ShiftColumnRight(idx int) tea.Msg { + if idx >= len(cc.colModel.Columns)-1 { + return nil + } + + col := cc.colModel.Columns[idx+1] + cc.colModel.Columns[idx+1], cc.colModel.Columns[idx] = cc.colModel.Columns[idx], col + + return ColumnsUpdated{} +} + +func (cc *ColumnsController) SetColumnsToResultSet() tea.Msg { + cc.colModel = columns.NewColumnsFromResultSet(cc.resultSet) + return ColumnsUpdated{} +} + +func (cc *ColumnsController) onNewResultSet(rs *models.ResultSet, op resultSetUpdateOp) { + cc.resultSet = rs + + if cc.colModel == nil || (op == resultSetUpdateInit || op == resultSetUpdateQuery) { + cc.colModel = columns.NewColumnsFromResultSet(rs) + } +} + +func (cc *ColumnsController) AddColumn(afterIndex int) tea.Msg { + return events.PromptForInput("column expr: ", func(value string) tea.Msg { + colExpr, err := queryexpr.Parse(value) + if err != nil { + return events.Error(err) + } + + newCol := columns.Column{ + Name: colExpr.String(), + Evaluator: columns.ExprFieldValueEvaluator{Expr: colExpr}, + } + + if afterIndex >= len(cc.colModel.Columns)-1 { + cc.colModel.Columns = append(cc.colModel.Columns, newCol) + } else { + newCols := make([]columns.Column, 0, len(cc.colModel.Columns)+1) + + newCols = append(newCols, cc.colModel.Columns[:afterIndex+1]...) + newCols = append(newCols, newCol) + newCols = append(newCols, cc.colModel.Columns[afterIndex+1:]...) + + cc.colModel.Columns = newCols + } + + return ColumnsUpdated{} + }) +} + +func (cc *ColumnsController) DeleteColumn(afterIndex int) tea.Msg { + if len(cc.colModel.Columns) == 0 { + return nil + } + + newCols := make([]columns.Column, 0, len(cc.colModel.Columns)-1) + newCols = append(newCols, cc.colModel.Columns[:afterIndex]...) + newCols = append(newCols, cc.colModel.Columns[afterIndex+1:]...) + cc.colModel.Columns = newCols + + return ColumnsUpdated{} +} diff --git a/internal/dynamo-browse/controllers/ctrlevents.go b/internal/dynamo-browse/controllers/ctrlevents.go new file mode 100644 index 0000000..bb26065 --- /dev/null +++ b/internal/dynamo-browse/controllers/ctrlevents.go @@ -0,0 +1,5 @@ +package controllers + +const ( + newResultSetEvent = "new_result_set" +) diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 64fa042..1433ac1 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -13,6 +13,11 @@ type SetTableItemView struct { type SettingsUpdated struct { } +type ColumnsUpdated struct { +} + +type MoveLeftmostDisplayedColumnInTableViewBy int + type NewResultSet struct { ResultSet *models.ResultSet currentFilter string @@ -59,3 +64,6 @@ type ResultSetUpdated struct { func (rs ResultSetUpdated) StatusMessage() string { return rs.statusMessage } + +type ShowColumnOverlay struct{} +type HideColumnOverlay struct{} diff --git a/internal/dynamo-browse/controllers/export.go b/internal/dynamo-browse/controllers/export.go new file mode 100644 index 0000000..ca2ea64 --- /dev/null +++ b/internal/dynamo-browse/controllers/export.go @@ -0,0 +1,57 @@ +package controllers + +import ( + "encoding/csv" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/pkg/errors" + "os" +) + +type ExportController struct { + state *State + columns *ColumnsController +} + +func NewExportController(state *State, columns *ColumnsController) *ExportController { + return &ExportController{state, columns} +} + +func (c *ExportController) ExportCSV(filename string) tea.Msg { + resultSet := c.state.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 := c.columns.Columns().VisibleColumns() + + colNames := make([]string, len(columns)) + for i, c := range columns { + colNames[i] = c.Name + } + if err := cw.Write(colNames); err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + + row := make([]string, len(columns)) + for _, item := range resultSet.Items() { + for i, col := range columns { + row[i], _ = models.AttributeToString(col.Evaluator.EvaluateForItem(item)) + } + if err := cw.Write(row); err != nil { + return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename)) + } + } + + return nil +} diff --git a/internal/dynamo-browse/controllers/export_test.go b/internal/dynamo-browse/controllers/export_test.go new file mode 100644 index 0000000..f70a387 --- /dev/null +++ b/internal/dynamo-browse/controllers/export_test.go @@ -0,0 +1,88 @@ +package controllers_test + +import ( + "github.com/stretchr/testify/assert" + "os" + "strings" + "testing" +) + +func TestExportController_ExportCSV(t *testing.T) { + t.Run("should export result set to CSV file", func(t *testing.T) { + srv := newService(t, serviceConfig{tableName: "bravo-table"}) + + tempFile := tempFile(t) + + invokeCommand(t, srv.readController.Init()) + invokeCommand(t, srv.exportController.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,222,This is another some value,1231,\n", + "bbb,131,,2468,foobar\n", + "foo,bar,This is some value,,\n", + }, "")) + }) + + t.Run("should return error if result set is not set", func(t *testing.T) { + srv := newService(t, serviceConfig{tableName: "non-existant-table"}) + + tempFile := tempFile(t) + + invokeCommandExpectingError(t, srv.readController.Init()) + invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile)) + }) + + t.Run("should honour new columns in CSV file", func(t *testing.T) { + srv := newService(t, serviceConfig{tableName: "alpha-table"}) + + tempFile := tempFile(t) + + invokeCommand(t, srv.readController.Init()) + + invokeCommandWithPrompt(t, srv.columnsController.AddColumn(0), "address.no") + invokeCommand(t, srv.columnsController.ShiftColumnLeft(1)) + invokeCommandWithPrompt(t, srv.columnsController.AddColumn(1), "address.street") + invokeCommand(t, srv.columnsController.ShiftColumnLeft(1)) + + invokeCommand(t, srv.exportController.ExportCSV(tempFile)) + + bts, err := os.ReadFile(tempFile) + assert.NoError(t, err) + + assert.Equal(t, string(bts), strings.Join([]string{ + "pk,address.no,address.street,sk,address,age,alpha,beta,gamma,useMailing\n", + "abc,123,Fake st.,111,,23,This is some value,,,true\n", + "abc,,,222,,,This is another some value,1231,,\n", + "bbb,,,131,,,,2468,foobar,\n", + }, "")) + }) + + t.Run("should honour hidden columns in CSV file", func(t *testing.T) { + srv := newService(t, serviceConfig{tableName: "alpha-table"}) + + tempFile := tempFile(t) + + invokeCommand(t, srv.readController.Init()) + + invokeCommand(t, srv.columnsController.ToggleVisible(1)) + invokeCommand(t, srv.columnsController.ToggleVisible(2)) + + invokeCommand(t, srv.exportController.ExportCSV(tempFile)) + + bts, err := os.ReadFile(tempFile) + assert.NoError(t, err) + + assert.Equal(t, string(bts), strings.Join([]string{ + "pk,age,alpha,beta,gamma,useMailing\n", + "abc,23,This is some value,,,true\n", + "abc,,This is another some value,1231,,\n", + "bbb,,,2468,foobar,\n", + }, "")) + }) + + // Hidden items? +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 3d2edd7..9e36567 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -2,7 +2,6 @@ package controllers import ( "context" - "encoding/csv" "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/audax/internal/common/ui/events" @@ -11,18 +10,30 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/models/serialisable" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" + bus "github.com/lmika/events" "github.com/pkg/errors" "golang.design/x/clipboard" "log" - "os" "strings" "sync" ) +type resultSetUpdateOp int + +const ( + resultSetUpdateInit resultSetUpdateOp = iota + resultSetUpdateQuery + resultSetUpdateFilter + resultSetUpdateSnapshotRestore + resultSetUpdateRescan + resultSetUpdateTouch +) + type TableReadController struct { tableService TableReadService workspaceService *workspaces.ViewSnapshotService itemRendererService *itemrenderer.Service + eventBus *bus.Bus tableName string loadFromLastView bool @@ -37,14 +48,15 @@ func NewTableReadController( tableService TableReadService, workspaceService *workspaces.ViewSnapshotService, itemRendererService *itemrenderer.Service, + eventBus *bus.Bus, tableName string, - loadFromLastView bool, ) *TableReadController { return &TableReadController{ state: state, tableService: tableService, workspaceService: workspaceService, itemRendererService: itemRendererService, + eventBus: eventBus, tableName: tableName, mutex: new(sync.Mutex), } @@ -94,7 +106,7 @@ func (c *TableReadController) ScanTable(name string) tea.Msg { } resultSet = c.tableService.Filter(resultSet, c.state.Filter()) - return c.setResultSetAndFilter(resultSet, c.state.Filter(), true) + return c.setResultSetAndFilter(resultSet, c.state.Filter(), true, resultSetUpdateInit) } func (c *TableReadController) PromptForQuery() tea.Msg { @@ -117,7 +129,7 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi newResultSet = c.tableService.Filter(newResultSet, newFilter) } - return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot) + return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery) } expr, err := queryexpr.Parse(query) @@ -134,7 +146,7 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi if newFilter != "" { newResultSet = c.tableService.Filter(newResultSet, newFilter) } - return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot) + return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery) }) } @@ -163,44 +175,11 @@ func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Msg { func (c *TableReadController) Rescan() tea.Msg { return c.doIfNoneDirty(func() tea.Msg { resultSet := c.state.ResultSet() - return c.doScan(context.Background(), resultSet, resultSet.Query, true) + return c.doScan(context.Background(), resultSet, resultSet.Query, true, resultSetUpdateRescan) }) } -func (c *TableReadController) ExportCSV(filename string) tea.Msg { - resultSet := c.state.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(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, query models.Queryable, pushBackstack bool) tea.Msg { +func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool, op resultSetUpdateOp) tea.Msg { newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) if err != nil { return events.Error(err) @@ -208,10 +187,10 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) - return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack) + return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack, op) } -func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool) tea.Msg { +func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool, op resultSetUpdateOp) tea.Msg { if pushBackstack { if err := c.workspaceService.PushSnapshot(resultSet, filter); err != nil { log.Printf("cannot push snapshot: %v", err) @@ -219,6 +198,9 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, } c.state.setResultSetAndFilter(resultSet, filter) + + c.eventBus.Fire(newResultSetEvent, resultSet, op) + return c.state.buildNewResultSetMessage("") } @@ -238,7 +220,7 @@ func (c *TableReadController) Filter() tea.Msg { resultSet := c.state.ResultSet() newResultSet := c.tableService.Filter(resultSet, value) - return c.setResultSetAndFilter(newResultSet, value, true) + return c.setResultSetAndFilter(newResultSet, value, true, resultSetUpdateFilter) }, } } @@ -286,7 +268,7 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter) newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter) - return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false) + return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false, resultSetUpdateSnapshotRestore) } tableInfo := currentResultSet.TableInfo diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index 46dd4a0..4453fbc 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -81,38 +81,6 @@ func TestTableReadController_Rescan(t *testing.T) { }) } -func TestTableReadController_ExportCSV(t *testing.T) { - t.Run("should export result set to CSV file", func(t *testing.T) { - srv := newService(t, serviceConfig{tableName: "bravo-table"}) - - tempFile := tempFile(t) - - invokeCommand(t, srv.readController.Init()) - invokeCommand(t, srv.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,222,This is another some value,1231,\n", - "bbb,131,,2468,foobar\n", - "foo,bar,This is some value,,\n", - }, "")) - }) - - t.Run("should return error if result set is not set", func(t *testing.T) { - srv := newService(t, serviceConfig{tableName: "non-existant-table"}) - - tempFile := tempFile(t) - - invokeCommandExpectingError(t, srv.readController.Init()) - invokeCommandExpectingError(t, srv.readController.ExportCSV(tempFile)) - }) - - // Hidden items? -} - func TestTableReadController_Query(t *testing.T) { t.Run("should run scan with filter based on user query", func(t *testing.T) { srv := newService(t, serviceConfig{tableName: "bravo-table"}) @@ -121,7 +89,7 @@ func TestTableReadController_Query(t *testing.T) { invokeCommand(t, srv.readController.Init()) invokeCommandWithPrompts(t, srv.readController.PromptForQuery(), `pk ^= "abc"`) - invokeCommand(t, srv.readController.ExportCSV(tempFile)) + invokeCommand(t, srv.exportController.ExportCSV(tempFile)) bts, err := os.ReadFile(tempFile) assert.NoError(t, err) @@ -138,7 +106,7 @@ func TestTableReadController_Query(t *testing.T) { tempFile := tempFile(t) invokeCommandExpectingError(t, srv.readController.Init()) - invokeCommandExpectingError(t, srv.readController.ExportCSV(tempFile)) + invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile)) }) } diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 9ff16f9..69dd727 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -375,7 +375,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Msg { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch) }, } } @@ -406,7 +406,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Msg { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch) }, } } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index ce1bc67..96560f4 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -12,6 +12,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/services/tables" workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" "github.com/lmika/audax/test/testdynamo" + bus "github.com/lmika/events" "github.com/stretchr/testify/assert" "testing" ) @@ -571,6 +572,8 @@ type services struct { readController *controllers.TableReadController writeController *controllers.TableWriteController settingsController *controllers.SettingsController + columnsController *controllers.ColumnsController + exportController *controllers.ExportController } type serviceConfig struct { @@ -590,11 +593,14 @@ func newService(t *testing.T, cfg serviceConfig) *services { provider := dynamo.NewProvider(client) service := tables.NewService(provider, settingStore) + eventBus := bus.New() state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, cfg.tableName, false) + readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, eventBus, cfg.tableName) writeController := controllers.NewTableWriteController(state, service, readController, settingStore) settingsController := controllers.NewSettingsController(settingStore) + columnsController := controllers.NewColumnsController(eventBus) + exportController := controllers.NewExportController(state, columnsController) if cfg.isReadOnly { if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil { @@ -608,5 +614,7 @@ func newService(t *testing.T, cfg serviceConfig) *services { readController: readController, writeController: writeController, settingsController: settingsController, + columnsController: columnsController, + exportController: exportController, } } diff --git a/internal/dynamo-browse/models/attrutils.go b/internal/dynamo-browse/models/attrutils.go index 20cfc73..1098040 100644 --- a/internal/dynamo-browse/models/attrutils.go +++ b/internal/dynamo-browse/models/attrutils.go @@ -34,7 +34,7 @@ func compareScalarAttributes(x, y types.AttributeValue) (int, bool) { return 0, false } -func attributeToString(x types.AttributeValue) (string, bool) { +func AttributeToString(x types.AttributeValue) (string, bool) { switch xVal := x.(type) { case *types.AttributeValueMemberS: return xVal.Value, true diff --git a/internal/dynamo-browse/models/columns/columns.go b/internal/dynamo-browse/models/columns/columns.go new file mode 100644 index 0000000..eb87711 --- /dev/null +++ b/internal/dynamo-browse/models/columns/columns.go @@ -0,0 +1,69 @@ +package columns + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" +) + +type Columns struct { + TableInfo *models.TableInfo + Columns []Column +} + +func NewColumnsFromResultSet(rs *models.ResultSet) *Columns { + rsCols := rs.Columns() + + cols := make([]Column, len(rsCols)) + for i, c := range rsCols { + cols[i] = Column{ + Name: c, + Evaluator: SimpleFieldValueEvaluator(c), + } + } + + return &Columns{ + TableInfo: rs.TableInfo, + Columns: cols, + } +} + +func (cols *Columns) VisibleColumns() []Column { + if cols == nil { + return []Column{} + } + + visibleCols := make([]Column, 0) + for _, col := range cols.Columns { + if col.Hidden { + continue + } + visibleCols = append(visibleCols, col) + } + return visibleCols +} + +type Column struct { + Name string + Evaluator FieldValueEvaluator + Hidden bool +} + +type FieldValueEvaluator interface { + EvaluateForItem(item models.Item) types.AttributeValue +} + +type SimpleFieldValueEvaluator string + +func (sfve SimpleFieldValueEvaluator) EvaluateForItem(item models.Item) types.AttributeValue { + return item[string(sfve)] +} + +type ExprFieldValueEvaluator struct { + Expr *queryexpr.QueryExpr +} + +func (sfve ExprFieldValueEvaluator) EvaluateForItem(item models.Item) types.AttributeValue { + val, _ := sfve.Expr.EvalItem(item) + return val +} diff --git a/internal/dynamo-browse/models/items.go b/internal/dynamo-browse/models/items.go index 21133ab..2acd3e9 100644 --- a/internal/dynamo-browse/models/items.go +++ b/internal/dynamo-browse/models/items.go @@ -2,7 +2,6 @@ package models import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/lmika/audax/internal/dynamo-browse/models/itemrender" ) type ItemIndex struct { @@ -34,9 +33,5 @@ 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]) + return AttributeToString(i[key]) } diff --git a/internal/dynamo-browse/models/queryexpr/ast.go b/internal/dynamo-browse/models/queryexpr/ast.go index 30c33fe..dd9aa03 100644 --- a/internal/dynamo-browse/models/queryexpr/ast.go +++ b/internal/dynamo-browse/models/queryexpr/ast.go @@ -2,6 +2,7 @@ package queryexpr import ( "github.com/alecthomas/participle/v2" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/pkg/errors" ) @@ -17,6 +18,10 @@ func (a *astExpr) evalToIR(tableInfo *models.TableInfo) (*irDisjunction, error) return a.Root.evalToIR(tableInfo) } +func (a *astExpr) evalItem(item models.Item) (types.AttributeValue, error) { + return a.Root.evalItem(item) +} + type astDisjunction struct { Operands []*astConjunction `parser:"@@ ('or' @@)*"` } @@ -25,10 +30,16 @@ type astConjunction struct { Operands []*astBinOp `parser:"@@ ('and' @@)*"` } +// TODO: do this properly type astBinOp struct { - Name string `parser:"@Ident"` - Op string `parser:"@('^' '=' | '=')"` - Value *astLiteralValue `parser:"@@"` + Ref *astDot `parser:"@@"` + Op string `parser:"( @('^' '=' | '=')"` + Value *astLiteralValue `parser:"@@ )?"` +} + +type astDot struct { + Name string `parser:"@Ident"` + Quals []string `parser:"('.' @Ident)*"` } type astLiteralValue struct { diff --git a/internal/dynamo-browse/models/queryexpr/binops.go b/internal/dynamo-browse/models/queryexpr/binops.go index 976fbd6..591bf17 100644 --- a/internal/dynamo-browse/models/queryexpr/binops.go +++ b/internal/dynamo-browse/models/queryexpr/binops.go @@ -2,6 +2,7 @@ package queryexpr import ( "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/pkg/errors" ) @@ -12,22 +13,40 @@ func (a *astBinOp) evalToIR(info *models.TableInfo) (irAtom, error) { return nil, err } + singleName, isSingleName := a.Ref.unqualifiedName() + if !isSingleName { + return nil, errors.Errorf("%v: cannot use dereferences", singleName) + } + switch a.Op { case "=": - return irFieldEq{name: a.Name, value: v}, nil + return irFieldEq{name: singleName, value: v}, nil case "^=": strValue, isStrValue := v.(string) if !isStrValue { return nil, errors.New("operand '^=' must be string") } - return irFieldBeginsWith{name: a.Name, prefix: strValue}, nil + return irFieldBeginsWith{name: singleName, prefix: strValue}, nil } return nil, errors.Errorf("unrecognised operator: %v", a.Op) } +func (a *astBinOp) evalItem(item models.Item) (types.AttributeValue, error) { + left, err := a.Ref.evalItem(item) + if err != nil { + return nil, err + } + + if a.Op == "" { + return left, nil + } + + return nil, errors.New("TODO") +} + func (a *astBinOp) String() string { - return a.Name + a.Op + a.Value.String() + return a.Ref.String() + a.Op + a.Value.String() } type irFieldEq struct { diff --git a/internal/dynamo-browse/models/queryexpr/conj.go b/internal/dynamo-browse/models/queryexpr/conj.go index 67841c9..314b235 100644 --- a/internal/dynamo-browse/models/queryexpr/conj.go +++ b/internal/dynamo-browse/models/queryexpr/conj.go @@ -2,6 +2,7 @@ package queryexpr import ( "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/pkg/errors" "strings" @@ -20,6 +21,14 @@ func (a *astConjunction) evalToIR(tableInfo *models.TableInfo) (*irConjunction, return &irConjunction{atoms: atoms}, nil } +func (a *astConjunction) evalItem(item models.Item) (types.AttributeValue, error) { + if len(a.Operands) == 1 { + return a.Operands[0].evalItem(item) + } + + return nil, errors.New("TODO") +} + func (d *astConjunction) String() string { sb := new(strings.Builder) for i, operand := range d.Operands { diff --git a/internal/dynamo-browse/models/queryexpr/disj.go b/internal/dynamo-browse/models/queryexpr/disj.go index 66bb20c..e45ae5d 100644 --- a/internal/dynamo-browse/models/queryexpr/disj.go +++ b/internal/dynamo-browse/models/queryexpr/disj.go @@ -2,6 +2,7 @@ package queryexpr import ( "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/pkg/errors" "strings" @@ -20,6 +21,14 @@ func (a *astDisjunction) evalToIR(tableInfo *models.TableInfo) (*irDisjunction, return &irDisjunction{conj: conj}, nil } +func (a *astDisjunction) evalItem(item models.Item) (types.AttributeValue, error) { + if len(a.Operands) == 1 { + return a.Operands[0].evalItem(item) + } + + return nil, errors.New("TODO") +} + func (d *astDisjunction) String() string { sb := new(strings.Builder) for i, operand := range d.Operands { diff --git a/internal/dynamo-browse/models/queryexpr/dot.go b/internal/dynamo-browse/models/queryexpr/dot.go new file mode 100644 index 0000000..001227a --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/dot.go @@ -0,0 +1,47 @@ +package queryexpr + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models" + "strings" +) + +func (dt *astDot) unqualifiedName() (string, bool) { + if len(dt.Quals) == 0 { + return dt.Name, true + } + return "", false +} + +func (dt *astDot) evalItem(item models.Item) (types.AttributeValue, error) { + res, hasV := item[dt.Name] + if !hasV { + return nil, NameNotFoundError(dt.String()) + } + + for i, qualName := range dt.Quals { + mapRes, isMapRes := res.(*types.AttributeValueMemberM) + if !isMapRes { + return nil, ValueNotAMapError(append([]string{dt.Name}, dt.Quals[:i+1]...)) + } + + res, hasV = mapRes.Value[qualName] + if !hasV { + return nil, NameNotFoundError(dt.String()) + } + } + + return res, nil +} + +func (a *astDot) String() string { + var sb strings.Builder + + sb.WriteString(a.Name) + for _, q := range a.Quals { + sb.WriteRune('.') + sb.WriteString(q) + } + + return sb.String() +} diff --git a/internal/dynamo-browse/models/queryexpr/errors.go b/internal/dynamo-browse/models/queryexpr/errors.go new file mode 100644 index 0000000..c37806b --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/errors.go @@ -0,0 +1,20 @@ +package queryexpr + +import ( + "fmt" + "strings" +) + +// NameNotFoundError is returned if the given name cannot be found +type NameNotFoundError string + +func (n NameNotFoundError) Error() string { + return fmt.Sprintf("%v: name not found", string(n)) +} + +// ValueNotAMapError is return if the given name is not a map +type ValueNotAMapError []string + +func (n ValueNotAMapError) Error() string { + return fmt.Sprintf("%v: name is not a map", strings.Join(n, ".")) +} diff --git a/internal/dynamo-browse/models/queryexpr/expr.go b/internal/dynamo-browse/models/queryexpr/expr.go index b3736d7..ebb6bb7 100644 --- a/internal/dynamo-browse/models/queryexpr/expr.go +++ b/internal/dynamo-browse/models/queryexpr/expr.go @@ -1,6 +1,9 @@ package queryexpr -import "github.com/lmika/audax/internal/dynamo-browse/models" +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models" +) type QueryExpr struct { ast *astExpr @@ -15,6 +18,10 @@ func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPl return ir.calcQuery(tableInfo) } +func (md *QueryExpr) EvalItem(item models.Item) (types.AttributeValue, error) { + return md.ast.evalItem(item) +} + func (md *QueryExpr) String() string { return md.ast.String() } diff --git a/internal/dynamo-browse/models/queryexpr/expr_test.go b/internal/dynamo-browse/models/queryexpr/expr_test.go index b8b8d2c..46c81d5 100644 --- a/internal/dynamo-browse/models/queryexpr/expr_test.go +++ b/internal/dynamo-browse/models/queryexpr/expr_test.go @@ -132,3 +132,67 @@ func TestModExpr_Query(t *testing.T) { }) }) } + +func TestQueryExpr_EvalItem(t *testing.T) { + var ( + item = models.Item{ + "alpha": &types.AttributeValueMemberS{Value: "alpha"}, + "bravo": &types.AttributeValueMemberN{Value: "123"}, + "charlie": &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "door": &types.AttributeValueMemberS{Value: "red"}, + "tree": &types.AttributeValueMemberS{Value: "green"}, + }, + }, + } + ) + + t.Run("simple values", func(t *testing.T) { + scenarios := []struct { + expr string + expected types.AttributeValue + }{ + // Simple values + {expr: `alpha`, expected: &types.AttributeValueMemberS{Value: "alpha"}}, + {expr: `bravo`, expected: &types.AttributeValueMemberN{Value: "123"}}, + {expr: `charlie`, expected: item["charlie"]}, + + // Dot values + {expr: `charlie.door`, expected: &types.AttributeValueMemberS{Value: "red"}}, + {expr: `charlie.tree`, expected: &types.AttributeValueMemberS{Value: "green"}}, + } + for _, scenario := range scenarios { + t.Run(scenario.expr, func(t *testing.T) { + modExpr, err := queryexpr.Parse(scenario.expr) + assert.NoError(t, err) + + res, err := modExpr.EvalItem(item) + assert.NoError(t, err) + + assert.Equal(t, scenario.expected, res) + }) + } + }) + + t.Run("expression errors", func(t *testing.T) { + scenarios := []struct { + expr string + expectedError error + }{ + {expr: `not_present`, expectedError: queryexpr.NameNotFoundError("not_present")}, + {expr: `alpha.bravo`, expectedError: queryexpr.ValueNotAMapError([]string{"alpha", "bravo"})}, + {expr: `charlie.tree.bla`, expectedError: queryexpr.ValueNotAMapError([]string{"charlie", "tree", "bla"})}, + } + + for _, scenario := range scenarios { + t.Run(scenario.expr, func(t *testing.T) { + modExpr, err := queryexpr.Parse(scenario.expr) + assert.NoError(t, err) + + res, err := modExpr.EvalItem(item) + assert.Nil(t, res) + assert.Equal(t, scenario.expectedError, err) + }) + } + }) +} diff --git a/internal/dynamo-browse/models/queryexpr/values.go b/internal/dynamo-browse/models/queryexpr/values.go index 94aa5cf..b21890b 100644 --- a/internal/dynamo-browse/models/queryexpr/values.go +++ b/internal/dynamo-browse/models/queryexpr/values.go @@ -8,6 +8,10 @@ import ( ) func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { + if a == nil { + return nil, nil + } + s, err := strconv.Unquote(a.StringVal) if err != nil { return nil, errors.Wrap(err, "cannot unquote string") @@ -16,6 +20,10 @@ func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { } func (a *astLiteralValue) goValue() (any, error) { + if a == nil { + return nil, nil + } + s, err := strconv.Unquote(a.StringVal) if err != nil { return nil, errors.Wrap(err, "cannot unquote string") @@ -24,5 +32,8 @@ func (a *astLiteralValue) goValue() (any, error) { } func (a *astLiteralValue) String() string { + if a == nil { + return "" + } return a.StringVal } diff --git a/internal/dynamo-browse/models/tableinfo.go b/internal/dynamo-browse/models/tableinfo.go index 0001684..3cccf92 100644 --- a/internal/dynamo-browse/models/tableinfo.go +++ b/internal/dynamo-browse/models/tableinfo.go @@ -6,6 +6,13 @@ type TableInfo struct { DefinedAttributes []string } +func (ti *TableInfo) Equal(other *TableInfo) bool { + return ti.Name == other.Name && + ti.Keys.PartitionKey == other.Keys.PartitionKey && + ti.Keys.SortKey == other.Keys.SortKey && + len(ti.DefinedAttributes) == len(other.DefinedAttributes) // Probably should be all +} + type KeyAttribute struct { PartitionKey string SortKey string diff --git a/internal/dynamo-browse/services/itemrenderer/service.go b/internal/dynamo-browse/services/itemrenderer/service.go index 055da9a..374bbd1 100644 --- a/internal/dynamo-browse/services/itemrenderer/service.go +++ b/internal/dynamo-browse/services/itemrenderer/service.go @@ -32,13 +32,13 @@ func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.Re seenColumns := make(map[string]struct{}) for _, colName := range resultSet.Columns() { seenColumns[colName] = struct{}{} - if r := item.Renderer(colName); r != nil { + if r := itemrender.ToRenderer(item[colName]); r != nil { s.renderItem(tabWriter, "", colName, r, styles) } } for k, _ := range item { if _, seen := seenColumns[k]; !seen { - if r := item.Renderer(k); r != nil { + if r := itemrender.ToRenderer(item[k]); r != nil { s.renderItem(tabWriter, "", k, r, styles) } } diff --git a/internal/dynamo-browse/ui/keybindings/defaults.go b/internal/dynamo-browse/ui/keybindings/defaults.go index da7ce86..7a996f1 100644 --- a/internal/dynamo-browse/ui/keybindings/defaults.go +++ b/internal/dynamo-browse/ui/keybindings/defaults.go @@ -4,6 +4,15 @@ import "github.com/charmbracelet/bubbles/key" func Default() *KeyBindings { return &KeyBindings{ + ColumnPopup: &FieldsPopupBinding{ + Close: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "close popup")), + ShiftColumnLeft: key.NewBinding(key.WithKeys("I", "shift column left")), + ShiftColumnRight: key.NewBinding(key.WithKeys("K", "shift column right")), + ToggleVisible: key.NewBinding(key.WithKeys(" ", "toggle column visible")), + ResetColumns: key.NewBinding(key.WithKeys("R", "reset columns")), + AddColumn: key.NewBinding(key.WithKeys("a", "add new column")), + DeleteColumn: key.NewBinding(key.WithKeys("d", "delete column")), + }, TableView: &TableKeyBinding{ MoveUp: key.NewBinding(key.WithKeys("i", "up")), MoveDown: key.NewBinding(key.WithKeys("k", "down")), @@ -25,6 +34,7 @@ func Default() *KeyBindings { CycleLayoutForward: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "cycle layout forward")), CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")), PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")), + ShowColumnOverlay: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "show column overlay")), Quit: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "quit")), }, } diff --git a/internal/dynamo-browse/ui/keybindings/keybindings.go b/internal/dynamo-browse/ui/keybindings/keybindings.go index 55b5dc6..8d13218 100644 --- a/internal/dynamo-browse/ui/keybindings/keybindings.go +++ b/internal/dynamo-browse/ui/keybindings/keybindings.go @@ -3,8 +3,19 @@ package keybindings import "github.com/charmbracelet/bubbles/key" type KeyBindings struct { - TableView *TableKeyBinding `keymap:"item-table"` - View *ViewKeyBindings `keymap:"view"` + ColumnPopup *FieldsPopupBinding `keymap:"column-popup"` + TableView *TableKeyBinding `keymap:"item-table"` + View *ViewKeyBindings `keymap:"view"` +} + +type FieldsPopupBinding struct { + Close key.Binding `keymap:"close"` + ShiftColumnLeft key.Binding `keymap:"shift-column-left"` + ShiftColumnRight key.Binding `keymap:"shift-column-right"` + ToggleVisible key.Binding `keymap:"toggle-column-visible"` + ResetColumns key.Binding `keymap:"reset-columns"` + AddColumn key.Binding `keymap:"add-column"` + DeleteColumn key.Binding `keymap:"delete-column"` } type TableKeyBinding struct { @@ -30,5 +41,6 @@ type ViewKeyBindings struct { CycleLayoutForward key.Binding `keymap:"cycle-layout-forward"` CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"` PromptForCommand key.Binding `keymap:"prompt-for-command"` + ShowColumnOverlay key.Binding `keymap:"show-column-overlay"` Quit key.Binding `keymap:"quit"` } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index a2b8132..f00efe5 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -9,6 +9,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" + "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/colselector" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dialogprompt" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemedit" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview" @@ -40,7 +41,9 @@ type Model struct { tableReadController *controllers.TableReadController tableWriteController *controllers.TableWriteController settingsController *controllers.SettingsController + exportController *controllers.ExportController commandController *commandctrl.CommandController + colSelector *colselector.Model itemEdit *dynamoitemedit.Model statusAndPrompt *statusandprompt.StatusAndPrompt tableSelect *tableselect.Model @@ -57,6 +60,8 @@ type Model struct { func NewModel( rc *controllers.TableReadController, wc *controllers.TableWriteController, + columnsController *controllers.ColumnsController, + exportController *controllers.ExportController, settingsController *controllers.SettingsController, itemRendererService *itemrenderer.Service, cc *commandctrl.CommandController, @@ -65,11 +70,12 @@ func NewModel( ) Model { uiStyles := styles.DefaultStyles - dtv := dynamotableview.New(defaultKeyMap.TableView, settingsController, uiStyles) + dtv := dynamotableview.New(defaultKeyMap.TableView, columnsController, settingsController, uiStyles) div := dynamoitemview.New(itemRendererService, uiStyles) mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div) - itemEdit := dynamoitemedit.NewModel(mainView) + colSelector := colselector.New(mainView, defaultKeyMap, columnsController) + itemEdit := dynamoitemedit.NewModel(colSelector) statusAndPrompt := statusandprompt.New(itemEdit, "", uiStyles.StatusAndPrompt) dialogPrompt := dialogprompt.New(statusAndPrompt) tableSelect := tableselect.New(dialogPrompt, uiStyles) @@ -88,7 +94,7 @@ func NewModel( if len(args) == 0 { return events.Error(errors.New("expected filename")) } - return rc.ExportCSV(args[0]) + return exportController.ExportCSV(args[0]) }, "unmark": commandctrl.NoArgCommand(rc.Unmark), "delete": commandctrl.NoArgCommand(wc.DeleteMarked), @@ -174,6 +180,7 @@ func NewModel( tableWriteController: wc, commandController: cc, itemEdit: itemEdit, + colSelector: colSelector, statusAndPrompt: statusAndPrompt, tableSelect: tableSelect, root: root, @@ -184,16 +191,6 @@ func NewModel( } } -func (m Model) Init() tea.Cmd { - // TODO: this should probably be moved somewhere else - rcFilename := os.ExpandEnv(initRCFilename) - if err := m.commandController.ExecuteFile(rcFilename); err != nil { - log.Println(err) - } - - return m.tableReadController.Init -} - func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case controllers.SetTableItemView: @@ -205,15 +202,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { events.SetStatus(msg.StatusMessage()), ) case tea.KeyMsg: - if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() { + // TODO: use modes here + if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() && !m.colSelector.ColSelectorVisible() { switch { case key.Matches(msg, m.keyMap.Mark): if idx := m.tableView.SelectedItemIndex(); idx >= 0 { - return m, func() tea.Msg { return m.tableWriteController.ToggleMark(idx) } + return m, events.SetTeaMessage(m.tableWriteController.ToggleMark(idx)) } case key.Matches(msg, m.keyMap.CopyItemToClipboard): if idx := m.tableView.SelectedItemIndex(); idx >= 0 { - return m, func() tea.Msg { return m.tableReadController.CopyItemToClipboard(idx) } + return m, events.SetTeaMessage(m.tableReadController.CopyItemToClipboard(idx)) } case key.Matches(msg, m.keyMap.Rescan): return m, m.tableReadController.Rescan @@ -226,22 +224,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keyMap.ViewForward): return m, m.tableReadController.ViewForward case key.Matches(msg, m.keyMap.CycleLayoutForward): - return m, func() tea.Msg { - return controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, 1, ViewModeCount)} - } + return m, events.SetTeaMessage(controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, 1, ViewModeCount)}) case key.Matches(msg, m.keyMap.CycleLayoutBackwards): - return m, func() tea.Msg { - return controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, -1, ViewModeCount)} - } + return m, events.SetTeaMessage(controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, -1, ViewModeCount)}) //case "e": // m.itemEdit.Visible() // return m, nil + case key.Matches(msg, m.keyMap.ShowColumnOverlay): + return m, events.SetTeaMessage(controllers.ShowColumnOverlay{}) case key.Matches(msg, m.keyMap.PromptForCommand): return m, m.commandController.Prompt case key.Matches(msg, m.keyMap.PromptForTable): - return m, func() tea.Msg { - return m.tableReadController.ListTables() - } + return m, events.SetTeaMessage(m.tableReadController.ListTables()) case key.Matches(msg, m.keyMap.Quit): return m, tea.Quit } @@ -253,6 +247,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m Model) Init() tea.Cmd { + // TODO: this should probably be moved somewhere else + rcFilename := os.ExpandEnv(initRCFilename) + if err := m.commandController.ExecuteFile(rcFilename); err != nil { + log.Println(err) + } + + return m.tableReadController.Init +} + func (m Model) View() string { return m.root.View() } diff --git a/internal/dynamo-browse/ui/teamodels/colselector/colmodel.go b/internal/dynamo-browse/ui/teamodels/colselector/colmodel.go new file mode 100644 index 0000000..eaeb984 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/colselector/colmodel.go @@ -0,0 +1,158 @@ +package colselector + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/controllers" + "github.com/lmika/audax/internal/dynamo-browse/models/columns" + "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" + "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout" + table "github.com/lmika/go-bubble-table" + "strings" +) + +var frameColor = lipgloss.Color("63") + +var frameStyle = lipgloss.NewStyle(). + Foreground(frameColor) +var style = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(frameColor) + +type colListModel struct { + keyBinding *keybindings.KeyBindings + colController *controllers.ColumnsController + + rows []table.Row + table table.Model +} + +func newColListModel(keyBinding *keybindings.KeyBindings, colController *controllers.ColumnsController) *colListModel { + tbl := table.New(table.SimpleColumns([]string{"", "Name"}), 100, 100) + tbl.SetRows([]table.Row{}) + + return &colListModel{ + keyBinding: keyBinding, + colController: colController, + table: tbl, + } +} + +func (c *colListModel) Init() tea.Cmd { + return nil +} + +func (m *colListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + // Column operations + case key.Matches(msg, m.keyBinding.ColumnPopup.ShiftColumnLeft): + return m, events.SetTeaMessage(m.shiftColumnUp(m.table.Cursor())) + case key.Matches(msg, m.keyBinding.ColumnPopup.ShiftColumnRight): + return m, events.SetTeaMessage(m.shiftColumnDown(m.table.Cursor())) + case key.Matches(msg, m.keyBinding.ColumnPopup.ToggleVisible): + return m, events.SetTeaMessage(m.colController.ToggleVisible(m.table.Cursor())) + case key.Matches(msg, m.keyBinding.ColumnPopup.Close): + return m, events.SetTeaMessage(controllers.HideColumnOverlay{}) + case key.Matches(msg, m.keyBinding.ColumnPopup.ResetColumns): + return m, events.SetTeaMessage(m.colController.SetColumnsToResultSet()) + case key.Matches(msg, m.keyBinding.ColumnPopup.AddColumn): + return m, events.SetTeaMessage(m.colController.AddColumn(m.table.Cursor())) + case key.Matches(msg, m.keyBinding.ColumnPopup.DeleteColumn): + return m, events.SetTeaMessage(m.colController.DeleteColumn(m.table.Cursor())) + + // Main table nav + case key.Matches(msg, m.keyBinding.TableView.ColLeft): + return m, events.SetTeaMessage(controllers.MoveLeftmostDisplayedColumnInTableViewBy(-1)) + case key.Matches(msg, m.keyBinding.TableView.ColRight): + return m, events.SetTeaMessage(controllers.MoveLeftmostDisplayedColumnInTableViewBy(1)) + + // Table nav + case key.Matches(msg, m.keyBinding.TableView.MoveUp): + m.table.GoUp() + return m, nil + case key.Matches(msg, m.keyBinding.TableView.MoveDown): + m.table.GoDown() + return m, nil + case key.Matches(msg, m.keyBinding.TableView.PageUp): + m.table.GoPageUp() + return m, nil + case key.Matches(msg, m.keyBinding.TableView.PageDown): + m.table.GoPageDown() + return m, nil + case key.Matches(msg, m.keyBinding.TableView.Home): + m.table.GoTop() + return m, nil + case key.Matches(msg, m.keyBinding.TableView.End): + m.table.GoBottom() + return m, nil + } + } + + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (c *colListModel) View() string { + innerView := lipgloss.JoinVertical( + lipgloss.Top, + lipgloss.PlaceHorizontal(overlayWidth-2, lipgloss.Center, "Columns"), + frameStyle.Render(strings.Repeat(lipgloss.NormalBorder().Top, 48)), + c.table.View(), + ) + + view := style.Width(overlayWidth - 2).Height(overlayHeight - 2).Render(innerView) + + return view +} + +func (c *colListModel) Resize(w, h int) layout.ResizingModel { + c.table.SetSize(overlayWidth-4, overlayHeight-4) + return c +} + +func (c *colListModel) refreshTable() { + colsFromController := c.colController.Columns() + if len(c.rows) != len(colsFromController.Columns) { + c.setColumnsFromModel(colsFromController) + } + c.table.UpdateView() +} + +func (c *colListModel) setColumnsFromModel(cols *columns.Columns) { + if cols == nil { + c.table.SetRows([]table.Row{}) + return + } + + colNames := make([]table.Row, len(cols.Columns)) + for i := range cols.Columns { + colNames[i] = colListRowModel{c} + } + c.rows = colNames + c.table.SetRows(colNames) + + if c.table.Cursor() >= len(c.rows) { + c.table.GoBottom() + } +} + +func (c *colListModel) shiftColumnUp(cursor int) tea.Msg { + msg := c.colController.ShiftColumnLeft(cursor) + if msg != nil { + c.table.GoUp() + } + return msg +} + +func (c *colListModel) shiftColumnDown(cursor int) tea.Msg { + msg := c.colController.ShiftColumnRight(cursor) + if msg != nil { + c.table.GoDown() + } + return msg +} diff --git a/internal/dynamo-browse/ui/teamodels/colselector/model.go b/internal/dynamo-browse/ui/teamodels/colselector/model.go new file mode 100644 index 0000000..f65cfd4 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/colselector/model.go @@ -0,0 +1,74 @@ +package colselector + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/dynamo-browse/controllers" + "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" + "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/utils" +) + +const ( + overlayWidth = 50 + overlayHeight = 25 +) + +type Model struct { + columnsController *controllers.ColumnsController + subModel tea.Model + colListModel *colListModel + compositor *layout.Compositor + w, h int +} + +func New(submodel tea.Model, keyBinding *keybindings.KeyBindings, columnsController *controllers.ColumnsController) *Model { + colListModel := newColListModel(keyBinding, columnsController) + + compositor := layout.NewCompositor(submodel) + + return &Model{ + columnsController: columnsController, + subModel: submodel, + compositor: compositor, + colListModel: colListModel, + } +} + +func (m *Model) Init() tea.Cmd { + return m.subModel.Init() +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cc utils.CmdCollector + switch msg := msg.(type) { + case controllers.ShowColumnOverlay: + m.colListModel.setColumnsFromModel(m.columnsController.Columns()) + m.compositor.SetOverlay(m.colListModel, m.w/2-overlayWidth/2, m.h/2-overlayHeight/2, overlayWidth, overlayHeight) + case controllers.HideColumnOverlay: + m.compositor.ClearOverlay() + case controllers.ColumnsUpdated: + m.colListModel.refreshTable() + m.subModel = cc.Collect(m.subModel.Update(msg)) + case tea.KeyMsg: + m.compositor = cc.Collect(m.compositor.Update(msg)).(*layout.Compositor) + default: + m.subModel = cc.Collect(m.subModel.Update(msg)) + } + return m, cc.Cmd() +} + +func (m *Model) View() string { + return m.compositor.View() +} + +func (m *Model) Resize(w, h int) layout.ResizingModel { + m.w, m.h = w, h + m.compositor.MoveOverlay(m.w/2-overlayWidth/2, m.h/2-overlayHeight/2) + m.subModel = layout.Resize(m.subModel, w, h) + m.colListModel = layout.Resize(m.colListModel, w, h).(*colListModel) + return m +} + +func (m *Model) ColSelectorVisible() bool { + return m.compositor.HasOverlay() +} diff --git a/internal/dynamo-browse/ui/teamodels/colselector/tblmodel.go b/internal/dynamo-browse/ui/teamodels/colselector/tblmodel.go new file mode 100644 index 0000000..0884428 --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/colselector/tblmodel.go @@ -0,0 +1,31 @@ +package colselector + +import ( + "fmt" + "github.com/charmbracelet/lipgloss" + table "github.com/lmika/go-bubble-table" + "io" +) + +type colListRowModel struct { + m *colListModel +} + +func (clr colListRowModel) Render(w io.Writer, model table.Model, index int) { + cols := clr.m.colController.Columns() + if cols == nil { + return + } + + var style lipgloss.Style + if index == model.Cursor() { + style = model.Styles.SelectedRow + } + + col := clr.m.colController.Columns().Columns[index] + if !col.Hidden { + fmt.Fprintln(w, style.Render(fmt.Sprintf(".\t%v", col.Name))) + } else { + fmt.Fprintln(w, style.Render(fmt.Sprintf("✕\t%v", col.Name))) + } +} diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go index 9c8827a..b4c8882 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/colmodel.go @@ -5,7 +5,7 @@ type columnModel struct { } func (cm columnModel) Len() int { - return len(cm.m.resultSet.Columns()[cm.m.colOffset:]) + 1 + return len(cm.m.columns[cm.m.colOffset:]) + 1 } func (cm columnModel) Header(index int) string { @@ -13,5 +13,5 @@ func (cm columnModel) Header(index int) string { return "" } - return cm.m.resultSet.Columns()[cm.m.colOffset+index-1] + return cm.m.columns[cm.m.colOffset+index-1].Name } diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 1020ad2..9a0c9d7 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/columns" "github.com/lmika/audax/internal/dynamo-browse/ui/keybindings" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame" @@ -26,35 +27,42 @@ type Setting interface { IsReadOnly() bool } +type ColumnsProvider interface { + Columns() *columns.Columns +} + type Model struct { - frameTitle frame.FrameTitle - table table.Model - w, h int - keyBinding *keybindings.TableKeyBinding - setting Setting + frameTitle frame.FrameTitle + table table.Model + w, h int + keyBinding *keybindings.TableKeyBinding + setting Setting + columnsProvider ColumnsProvider // model state isReadOnly bool colOffset int rows []table.Row + columns []columns.Column resultSet *models.ResultSet } -func New(keyBinding *keybindings.TableKeyBinding, setting Setting, uiStyles styles.Styles) *Model { - tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100) - rows := make([]table.Row, 0) - tbl.SetRows(rows) - +func New(keyBinding *keybindings.TableKeyBinding, columnsProvider ColumnsProvider, setting Setting, uiStyles styles.Styles) *Model { frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames) isReadOnly := setting.IsReadOnly() - return &Model{ - isReadOnly: isReadOnly, - frameTitle: frameTitle, - table: tbl, - keyBinding: keyBinding, - setting: setting, + model := &Model{ + isReadOnly: isReadOnly, + frameTitle: frameTitle, + keyBinding: keyBinding, + setting: setting, + columnsProvider: columnsProvider, } + + model.table = table.New(columnModel{model}, 100, 100) + model.table.SetRows([]table.Row{}) + + return model } func (m *Model) Init() tea.Cmd { @@ -67,9 +75,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resultSet = msg.ResultSet m.updateTable() return m, m.postSelectedItemChanged + case controllers.ColumnsUpdated: + m.rebuildTable(&m.table) + return m, m.postSelectedItemChanged case controllers.SettingsUpdated: m.updateTableHeading() return m, nil + case controllers.MoveLeftmostDisplayedColumnInTableViewBy: + m.setLeftmostDisplayedColumn(m.colOffset + int(msg)) + return m, nil case tea.KeyMsg: switch { // Table nav @@ -106,8 +120,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.columnsProvider.Columns().Columns) { + m.colOffset = len(m.columnsProvider.Columns().Columns) - 1 } else { m.colOffset = newCol } @@ -139,14 +153,29 @@ func (m *Model) updateTableHeading() { func (m *Model) updateTable() { m.updateTableHeading() m.colOffset = 0 - m.rebuildTable() + m.rebuildTable(nil) } -func (m *Model) rebuildTable() { +func (m *Model) rebuildTable(targetTbl *table.Model) { + var tbl table.Model + resultSet := m.resultSet - newTbl := table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight()) + // Use the target table model if you can, but if it's nil or the number of rows is smaller than the + // existing table, create a new one + if targetTbl == nil || len(resultSet.Items()) > len(m.rows) { + tbl = table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight()) + if targetTbl != nil { + tbl.GoBottom() + } + } else { + tbl = *targetTbl + } + + m.columns = m.columnsProvider.Columns().VisibleColumns() + newRows := make([]table.Row, 0) + for i, r := range resultSet.Items() { if resultSet.Hidden(i) { continue @@ -161,8 +190,9 @@ func (m *Model) rebuildTable() { } m.rows = newRows - newTbl.SetRows(newRows) - m.table = newTbl + tbl.SetRows(newRows) + + m.table = tbl } func (m *Model) SelectedItemIndex() int { diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go index b05dafc..feeb5fd 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/tblmodel.go @@ -3,6 +3,7 @@ package dynamotableview import ( "fmt" "github.com/charmbracelet/lipgloss" + "github.com/lmika/audax/internal/dynamo-browse/models/itemrender" "io" "strings" @@ -63,12 +64,12 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { sb.WriteString(metaInfoStyle.Render("⋅\t")) } - for i, colName := range mtr.resultSet.Columns()[mtr.model.colOffset:] { + for i, col := range mtr.model.columns[mtr.model.colOffset:] { if i > 0 { sb.WriteString(style.Render("\t")) } - if r := mtr.item.Renderer(colName); r != nil { + if r := itemrender.ToRenderer(col.Evaluator.EvaluateForItem(mtr.item)); r != nil { sb.WriteString(style.Render(r.StringValue())) if mi := r.MetaInfo(); mi != "" { sb.WriteString(metaInfoStyle.Render(mi)) diff --git a/internal/dynamo-browse/ui/teamodels/layout/composit.go b/internal/dynamo-browse/ui/teamodels/layout/composit.go index 055521f..f4ff5f5 100644 --- a/internal/dynamo-browse/ui/teamodels/layout/composit.go +++ b/internal/dynamo-browse/ui/teamodels/layout/composit.go @@ -4,18 +4,21 @@ import ( "bufio" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/muesli/ansi" + "github.com/muesli/reflow/truncate" "strings" ) type Compositor struct { - background ResizingModel + background tea.Model - foreground ResizingModel + foreground tea.Model foreX, foreY int foreW, foreH int } -func NewCompositor(background ResizingModel) *Compositor { +func NewCompositor(background tea.Model) *Compositor { return &Compositor{ background: background, } @@ -26,9 +29,12 @@ func (c *Compositor) Init() tea.Cmd { } 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) + var cmd tea.Cmd + if c.foreground != nil { + c.foreground, cmd = c.foreground.Update(msg) + } else { + c.background, cmd = c.background.Update(msg) + } return c, cmd } @@ -38,6 +44,18 @@ func (c *Compositor) SetOverlay(m ResizingModel, x, y, w, h int) { c.foreW, c.foreH = w, h } +func (c *Compositor) MoveOverlay(x, y int) { + c.foreX, c.foreY = x, y +} + +func (c *Compositor) ClearOverlay() { + c.foreground = nil +} + +func (c *Compositor) HasOverlay() bool { + return c.foreground != nil +} + func (c *Compositor) View() string { if c.foreground == nil { return c.background.View() @@ -46,6 +64,7 @@ func (c *Compositor) View() string { // Need to compose backgroundView := c.background.View() foregroundViewLines := strings.Split(c.foreground.View(), "\n") + _ = foregroundViewLines backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView)) compositeOutput := new(strings.Builder) @@ -58,7 +77,7 @@ func (c *Compositor) View() string { line := backgroundScanner.Text() if r >= c.foreY && r < c.foreY+c.foreH { - compositeOutput.WriteString(line[:c.foreX]) + compositeOutput.WriteString(truncate.String(line, uint(c.foreX))) foregroundScanPos := r - c.foreY if foregroundScanPos < len(foregroundViewLines) { @@ -66,7 +85,10 @@ func (c *Compositor) View() string { compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" "))) } - compositeOutput.WriteString(line[c.foreX+c.foreW:]) + rightStr := c.renderBackgroundUpTo(line, c.foreX+c.foreW) + + // Need to find a way to cut the string here + compositeOutput.WriteString(rightStr) } else { compositeOutput.WriteString(line) } @@ -77,9 +99,33 @@ func (c *Compositor) View() string { } func (c *Compositor) Resize(w, h int) ResizingModel { - c.background = c.background.Resize(w, h) + c.background = Resize(c.background, w, h) if c.foreground != nil { - c.foreground = c.foreground.Resize(c.foreW, c.foreH) + c.foreground = Resize(c.foreground, c.foreW, c.foreH) } return c } + +func (c *Compositor) renderBackgroundUpTo(line string, x int) string { + ansiSequences := new(strings.Builder) + posX := 0 + inAnsi := false + + for i, c := range line { + if c == ansi.Marker { + ansiSequences.WriteRune(c) + inAnsi = true + } else if inAnsi { + ansiSequences.WriteRune(c) + if ansi.IsTerminator(c) { + inAnsi = false + } + } else { + if posX >= x { + return ansiSequences.String() + line[i:] + } + posX += runewidth.RuneWidth(c) + } + } + return "" +} diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index 8a7f7c2..31435fe 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -2,13 +2,13 @@ package main import ( "context" + "flag" "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/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" "github.com/lmika/audax/internal/dynamo-browse/services/tables" @@ -18,9 +18,13 @@ import ( ) func main() { + var flagSeed = flag.Int64("seed", 0, "random seed to use") + var flagCount = flag.Int("count", 500, "number of items to produce") + flag.Parse() + ctx := context.Background() tableName := "business-addresses" - totalItems := 500 + totalItems := *flagCount cfg, err := config.LoadDefaultConfig(ctx) if err != nil { @@ -53,8 +57,11 @@ func main() { _, _ = tableService, tableInfo + log.Printf("using seed: %v", *flagSeed) + gofakeit.Seed(*flagSeed) + for i := 0; i < totalItems; i++ { - key := uuid.New().String() + key := gofakeit.UUID() if err := tableService.Put(ctx, tableInfo, models.Item{ "pk": &types.AttributeValueMemberS{Value: key}, "sk": &types.AttributeValueMemberS{Value: key}, @@ -64,6 +71,12 @@ func main() { "phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()}, "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, "officeOpened": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()}, + "colors": &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "door": &types.AttributeValueMemberS{Value: gofakeit.Color()}, + "front": &types.AttributeValueMemberS{Value: gofakeit.Color()}, + }, + }, "ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{ &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))}, &types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))},