diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index e0695cf..a5efea6 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -11,9 +11,12 @@ import ( "github.com/lmika/audax/internal/common/ui/commandctrl" "github.com/lmika/audax/internal/common/ui/logging" "github.com/lmika/audax/internal/common/ui/osstyle" + "github.com/lmika/audax/internal/common/workspaces" "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore" "github.com/lmika/audax/internal/dynamo-browse/services/tables" + workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" "github.com/lmika/audax/internal/dynamo-browse/ui" "github.com/lmika/gopkgs/cli" "log" @@ -25,6 +28,7 @@ func main() { var flagTable = flag.String("t", "", "dynamodb table name") var flagLocal = flag.String("local", "", "local endpoint") var flagDebug = flag.String("debug", "", "file to log debug messages") + var flagWorkspace = flag.String("w", "", "workspace file") flag.Parse() ctx := context.Background() @@ -34,6 +38,16 @@ func main() { cli.Fatalf("cannot load AWS config: %v", err) } + closeFn := logging.EnableLogging(*flagDebug) + defer closeFn() + + wsManager := workspaces.New(workspaces.MetaInfo{Command: "dynamo-browse"}) + ws, err := wsManager.OpenOrCreate(*flagWorkspace) + if err != nil { + cli.Fatalf("cannot create workspace: %v", ws) + } + defer ws.Close() + var dynamoClient *dynamodb.Client if *flagLocal != "" { host, port, err := net.SplitHostPort(*flagLocal) @@ -53,11 +67,13 @@ func main() { } dynamoProvider := dynamo.NewProvider(dynamoClient) + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) tableService := tables.NewService(dynamoProvider) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) state := controllers.NewState() - tableReadController := controllers.NewTableReadController(state, tableService, *flagTable) + tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, *flagTable) tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController) commandController := commandctrl.NewCommandController() @@ -68,9 +84,6 @@ func main() { p := tea.NewProgram(model, tea.WithAltScreen()) - 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 { diff --git a/internal/common/workspaces/manager.go b/internal/common/workspaces/manager.go new file mode 100644 index 0000000..3a1834d --- /dev/null +++ b/internal/common/workspaces/manager.go @@ -0,0 +1,46 @@ +package workspaces + +import ( + "github.com/asdine/storm" + "github.com/pkg/errors" + "log" + "os" +) + +type MetaInfo struct { + Command string +} + +type Manager struct { + metainfo MetaInfo +} + +func New(metaInfo MetaInfo) *Manager { + return &Manager{metainfo: metaInfo} +} + +func (m *Manager) OpenOrCreate(filename string) (*Workspace, error) { + if filename == "" { + return m.CreateTemp() + } + return m.Open(filename) +} + +func (m *Manager) Open(filename string) (*Workspace, error) { + db, err := storm.Open(filename) + if err != nil { + return nil, errors.Wrapf(err, "cannot open workspace at %v", filename) + } + log.Printf("open workspace: %v", filename) + return &Workspace{db: db}, nil +} + +func (m *Manager) CreateTemp() (*Workspace, error) { + workspaceFile, err := os.CreateTemp("", m.metainfo.Command+"*.workspace") + if err != nil { + return nil, errors.Wrapf(err, "cannot create workspace file") + } + workspaceFile.Close() // We just need the filename + + return m.Open(workspaceFile.Name()) +} diff --git a/internal/common/workspaces/workspaces.go b/internal/common/workspaces/workspaces.go new file mode 100644 index 0000000..00bcc87 --- /dev/null +++ b/internal/common/workspaces/workspaces.go @@ -0,0 +1,19 @@ +package workspaces + +import ( + "github.com/asdine/storm" + "log" +) + +type Workspace struct { + db *storm.DB +} + +func (ws *Workspace) DB() *storm.DB { + return ws.db +} + +func (ws *Workspace) Close() { + log.Printf("close workspace") + ws.db.Close() +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index c9a3861..a5caa5a 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -7,28 +7,30 @@ import ( "github.com/lmika/audax/internal/common/ui/events" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" + "github.com/lmika/audax/internal/dynamo-browse/services/workspaces" "github.com/pkg/errors" + "log" "os" "sync" ) type TableReadController struct { - tableService TableReadService - tableName string + tableService TableReadService + workspaceService *workspaces.ViewSnapshotService + tableName string // state mutex *sync.Mutex state *State - //resultSet *models.ResultSet - //filter string } -func NewTableReadController(state *State, tableService TableReadService, tableName string) *TableReadController { +func NewTableReadController(state *State, tableService TableReadService, workspaceService *workspaces.ViewSnapshotService, tableName string) *TableReadController { return &TableReadController{ - state: state, - tableService: tableService, - tableName: tableName, - mutex: new(sync.Mutex), + state: state, + tableService: tableService, + workspaceService: workspaceService, + tableName: tableName, + mutex: new(sync.Mutex), } } @@ -70,8 +72,9 @@ func (c *TableReadController) ScanTable(name string) tea.Cmd { if err != nil { return events.Error(err) } + resultSet = c.tableService.Filter(resultSet, c.state.Filter()) - return c.setResultSetAndFilter(resultSet, c.state.Filter()) + return c.setResultSetAndFilter(resultSet, c.state.Filter(), true) } } @@ -80,62 +83,75 @@ func (c *TableReadController) PromptForQuery() tea.Cmd { return events.PromptForInputMsg{ Prompt: "query: ", OnDone: func(value string) tea.Cmd { - if value == "" { - return func() tea.Msg { - resultSet := c.state.ResultSet() - return c.doScan(context.Background(), resultSet, nil) - } + return func() tea.Msg { + return c.runQuery(c.state.ResultSet().TableInfo, value, "", true) } - - expr, err := queryexpr.Parse(value) - if err != nil { - return events.SetError(err) - } - - return c.doIfNoneDirty(func() tea.Msg { - resultSet := c.state.ResultSet() - newResultSet, err := c.tableService.ScanOrQuery(context.Background(), resultSet.TableInfo, expr) - if err != nil { - return events.Error(err) - } - - return c.setResultSetAndFilter(newResultSet, "") - }) }, } } } -func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Cmd { +func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFilter string, pushSnapshot bool) tea.Msg { + if query == "" { + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, nil) + if err != nil { + return events.Error(err) + } + + if newFilter != "" { + newResultSet = c.tableService.Filter(newResultSet, newFilter) + } + + return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot) + } + + expr, err := queryexpr.Parse(query) + if err != nil { + return events.SetError(err) + } + + return c.doIfNoneDirty(func() tea.Msg { + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, expr) + if err != nil { + return events.Error(err) + } + + if newFilter != "" { + newResultSet = c.tableService.Filter(newResultSet, newFilter) + } + return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot) + }) +} + +func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Msg { var anyDirty = false for i := 0; i < len(c.state.ResultSet().Items()); i++ { anyDirty = anyDirty || c.state.ResultSet().IsDirty(i) } if !anyDirty { - return cmd + return cmd() } - return func() tea.Msg { - return events.PromptForInputMsg{ - Prompt: "reset modified items? ", - OnDone: func(value string) tea.Cmd { - if value != "y" { - return events.SetStatus("operation aborted") - } + return events.PromptForInputMsg{ + Prompt: "reset modified items? ", + OnDone: func(value string) tea.Cmd { + if value != "y" { + return events.SetStatus("operation aborted") + } - return cmd - }, - } + return cmd + }, } - } func (c *TableReadController) Rescan() tea.Cmd { - return c.doIfNoneDirty(func() tea.Msg { - resultSet := c.state.ResultSet() - return c.doScan(context.Background(), resultSet, resultSet.Query) - }) + return func() tea.Msg { + return c.doIfNoneDirty(func() tea.Msg { + resultSet := c.state.ResultSet() + return c.doScan(context.Background(), resultSet, resultSet.Query, true) + }) + } } func (c *TableReadController) ExportCSV(filename string) tea.Cmd { @@ -173,7 +189,7 @@ func (c *TableReadController) ExportCSV(filename string) tea.Cmd { } } -func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable) tea.Msg { +func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool) tea.Msg { newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query) if err != nil { return events.Error(err) @@ -181,10 +197,16 @@ 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()) + return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack) } -func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { +func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool) tea.Msg { + if pushBackstack { + if err := c.workspaceService.PushSnapshot(resultSet, filter); err != nil { + log.Printf("cannot push snapshot: %v", err) + } + } + c.state.setResultSetAndFilter(resultSet, filter) return c.state.buildNewResultSetMessage("") } @@ -209,9 +231,46 @@ func (c *TableReadController) Filter() tea.Cmd { resultSet := c.state.ResultSet() newResultSet := c.tableService.Filter(resultSet, value) - return c.setResultSetAndFilter(newResultSet, value) + return c.setResultSetAndFilter(newResultSet, value, true) } }, } } } + +func (c *TableReadController) ViewBack() tea.Cmd { + return func() tea.Msg { + viewSnapshot, err := c.workspaceService.PopSnapshot() + if err != nil { + return events.Error(err) + } else if viewSnapshot == nil { + return events.StatusMsg("Backstack is empty") + } + + currentResultSet := c.state.ResultSet() + + var currentQueryExpr string + if currentResultSet.Query != nil { + currentQueryExpr = currentResultSet.Query.String() + } + + if viewSnapshot.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Query == currentQueryExpr { + log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter) + + newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter) + return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false) + } + + tableInfo := currentResultSet.TableInfo + if viewSnapshot.TableName != currentResultSet.TableInfo.Name { + tableInfo, err = c.tableService.Describe(context.Background(), viewSnapshot.TableName) + if err != nil { + return events.Error(err) + } + } + + log.Printf("backstack: running query: table = '%v', query = '%v', filter = '%v'", + tableInfo.Name, viewSnapshot.Query, viewSnapshot.Filter) + return c.runQuery(tableInfo, viewSnapshot.Query, viewSnapshot.Filter, false) + } +} diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index 3ac1794..e2f3329 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -4,9 +4,12 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/common/workspaces" "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore" "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" "github.com/stretchr/testify/assert" "os" @@ -17,11 +20,14 @@ import ( func TestTableReadController_InitTable(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + 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(controllers.NewState(), service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "") cmd := readController.Init() event := cmd() @@ -30,7 +36,7 @@ func TestTableReadController_InitTable(t *testing.T) { }) t.Run("should scan table if table name provided", func(t *testing.T) { - readController := controllers.NewTableReadController(controllers.NewState(), service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "") cmd := readController.Init() event := cmd() @@ -42,9 +48,12 @@ func TestTableReadController_InitTable(t *testing.T) { func TestTableReadController_ListTables(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(controllers.NewState(), service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "") t.Run("returns a list of tables", func(t *testing.T) { cmd := readController.ListTables() @@ -65,10 +74,13 @@ func TestTableReadController_ListTables(t *testing.T) { func TestTableReadController_Rescan(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + provider := dynamo.NewProvider(client) service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "bravo-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "bravo-table") t.Run("should perform a rescan", func(t *testing.T) { invokeCommand(t, readController.Init()) @@ -99,9 +111,12 @@ func TestTableReadController_Rescan(t *testing.T) { func TestTableReadController_ExportCSV(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "bravo-table") t.Run("should export result set to CSV file", func(t *testing.T) { tempFile := tempFile(t) @@ -122,7 +137,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(controllers.NewState(), service, "non-existant-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "non-existant-table") invokeCommandExpectingError(t, readController.Init()) invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) @@ -134,9 +149,12 @@ func TestTableReadController_ExportCSV(t *testing.T) { func TestTableReadController_Query(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(controllers.NewState(), service, "bravo-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "bravo-table") t.Run("should run scan with filter based on user query", func(t *testing.T) { tempFile := tempFile(t) @@ -156,7 +174,7 @@ func TestTableReadController_Query(t *testing.T) { 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") + readController := controllers.NewTableReadController(controllers.NewState(), service, workspaceService, "non-existant-table") invokeCommandExpectingError(t, readController.Init()) invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) @@ -177,6 +195,19 @@ func tempFile(t *testing.T) string { return tempFile.Name() } +func testWorkspace(t *testing.T) *workspaces.Workspace { + wsTempFile := tempFile(t) + + wsManager := workspaces.New(workspaces.MetaInfo{Command: "dynamo-browse"}) + ws, err := wsManager.Open(wsTempFile) + if err != nil { + t.Fatalf("cannot create workspace manager: %v", err) + } + t.Cleanup(func() { ws.Close() }) + + return ws +} + func invokeCommand(t *testing.T, cmd tea.Cmd) tea.Msg { msg := cmd() diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 61958a1..5f79d7d 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -400,7 +400,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Cmd { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false) } }, } @@ -431,7 +431,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Cmd { return events.Error(err) } - return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query) + return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false) } }, } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index cf6f1e4..ff29b8c 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -6,13 +6,18 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore" "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" "github.com/stretchr/testify/assert" "testing" ) func TestTableWriteController_NewItem(t *testing.T) { + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + t.Run("should add an item with pk and sk set at the end of the result set", func(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) @@ -20,7 +25,7 @@ func TestTableWriteController_NewItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -43,6 +48,9 @@ func TestTableWriteController_NewItem(t *testing.T) { } func TestTableWriteController_SetAttributeValue(t *testing.T) { + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + t.Run("should preserve the type of the field if unspecified", func(t *testing.T) { scenarios := []struct { @@ -80,7 +88,7 @@ func TestTableWriteController_SetAttributeValue(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -100,7 +108,7 @@ func TestTableWriteController_SetAttributeValue(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -154,7 +162,7 @@ func TestTableWriteController_SetAttributeValue(t *testing.T) { for _, scenario := range scenarios { t.Run(fmt.Sprintf("should change the value of a field to type %v", scenario.attrType), func(t *testing.T) { state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -175,7 +183,7 @@ func TestTableWriteController_SetAttributeValue(t *testing.T) { t.Run(fmt.Sprintf("should change value of nested field to type %v", scenario.attrType), func(t *testing.T) { state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -205,12 +213,15 @@ func TestTableWriteController_SetAttributeValue(t *testing.T) { func TestTableWriteController_DeleteAttribute(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + 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") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -226,7 +237,7 @@ func TestTableWriteController_DeleteAttribute(t *testing.T) { t.Run("should delete attribute of map", func(t *testing.T) { state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -247,6 +258,9 @@ func TestTableWriteController_DeleteAttribute(t *testing.T) { } func TestTableWriteController_PutItem(t *testing.T) { + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + t.Run("should put the selected item if dirty", func(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) @@ -254,7 +268,7 @@ func TestTableWriteController_PutItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) // Read the table @@ -281,7 +295,7 @@ func TestTableWriteController_PutItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) // Read the table @@ -312,7 +326,7 @@ func TestTableWriteController_PutItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) // Read the table @@ -326,6 +340,9 @@ func TestTableWriteController_PutItem(t *testing.T) { } func TestTableWriteController_PutItems(t *testing.T) { + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + t.Run("should put all dirty items if none are marked", func(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) @@ -333,7 +350,7 @@ func TestTableWriteController_PutItems(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -361,7 +378,7 @@ func TestTableWriteController_PutItems(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -397,7 +414,7 @@ func TestTableWriteController_PutItems(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) invokeCommand(t, readController.Init()) @@ -428,6 +445,9 @@ func TestTableWriteController_PutItems(t *testing.T) { } func TestTableWriteController_TouchItem(t *testing.T) { + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + t.Run("should put the selected item if unmodified", func(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) @@ -435,7 +455,7 @@ func TestTableWriteController_TouchItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) // Read the table @@ -461,7 +481,7 @@ func TestTableWriteController_TouchItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) // Read the table @@ -477,6 +497,9 @@ func TestTableWriteController_TouchItem(t *testing.T) { } func TestTableWriteController_NoisyTouchItem(t *testing.T) { + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(testWorkspace(t)) + workspaceService := workspaces_service.NewService(resultSetSnapshotStore) + t.Run("should delete and put the selected item if unmodified", func(t *testing.T) { client := testdynamo.SetupTestTable(t, testData) @@ -484,7 +507,7 @@ func TestTableWriteController_NoisyTouchItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) // Read the table @@ -510,7 +533,7 @@ func TestTableWriteController_NoisyTouchItem(t *testing.T) { service := tables.NewService(provider) state := controllers.NewState() - readController := controllers.NewTableReadController(state, service, "alpha-table") + readController := controllers.NewTableReadController(state, service, workspaceService, "alpha-table") writeController := controllers.NewTableWriteController(state, service, readController) // Read the table diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 7bd9469..c13b901 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -3,9 +3,8 @@ package models import "sort" type ResultSet struct { - TableInfo *TableInfo - Query Queryable - //Columns []string + TableInfo *TableInfo + Query Queryable items []Item attributes []ItemAttribute diff --git a/internal/dynamo-browse/models/serialisable/viewsnapshot.go b/internal/dynamo-browse/models/serialisable/viewsnapshot.go new file mode 100644 index 0000000..80cd04a --- /dev/null +++ b/internal/dynamo-browse/models/serialisable/viewsnapshot.go @@ -0,0 +1,14 @@ +package serialisable + +import ( + "time" +) + +type ViewSnapshot struct { + ID int64 `storm:"id,increment"` + BackLink int64 `storm:"index"` + Time time.Time + TableName string + Query string + Filter string +} diff --git a/internal/dynamo-browse/providers/workspacestore/resultsetsnapshot.go b/internal/dynamo-browse/providers/workspacestore/resultsetsnapshot.go new file mode 100644 index 0000000..09ef0cf --- /dev/null +++ b/internal/dynamo-browse/providers/workspacestore/resultsetsnapshot.go @@ -0,0 +1,78 @@ +package workspacestore + +import ( + "github.com/asdine/storm" + "github.com/lmika/audax/internal/common/workspaces" + "github.com/lmika/audax/internal/dynamo-browse/models/serialisable" + "github.com/pkg/errors" + "log" +) + +const resultSetSnapshotsBucket = "ResultSetSnapshots" + +type ResultSetSnapshotStore struct { + ws storm.Node +} + +func NewResultSetSnapshotStore(ws *workspaces.Workspace) *ResultSetSnapshotStore { + return &ResultSetSnapshotStore{ + ws: ws.DB().From(resultSetSnapshotsBucket), + } +} + +func (s *ResultSetSnapshotStore) Save(rs *serialisable.ViewSnapshot) error { + if err := s.ws.Save(rs); err != nil { + return errors.Wrap(err, "cannot save result set") + } + log.Printf("saved result set: table='%v', query='%v', filter='%v'", rs.TableName, rs.Query, rs.Filter) + return nil +} + +func (s *ResultSetSnapshotStore) SetAsHead(resultSetID int64) error { + if resultSetID == 0 { + if err := s.ws.Delete("head", "id"); err != nil { + return errors.Wrap(err, "cannot remove head") + } + return nil + } + + if err := s.ws.Set("head", "id", resultSetID); err != nil { + return errors.Wrap(err, "cannot set as head") + } + log.Printf("saved result set head") + return nil +} + +func (s *ResultSetSnapshotStore) Head() (*serialisable.ViewSnapshot, error) { + var headResultSetID int64 + if err := s.ws.Get("head", "id", &headResultSetID); err != nil && !errors.Is(err, storm.ErrNotFound) { + return nil, errors.Wrap(err, "cannot get head") + } + + var rss serialisable.ViewSnapshot + if err := s.ws.One("ID", headResultSetID, &rss); err != nil { + if errors.Is(err, storm.ErrNotFound) { + return nil, nil + } else { + return nil, errors.Wrap(err, "cannot get head") + } + } + + return &rss, nil +} + +func (s *ResultSetSnapshotStore) Remove(resultSetId int64) error { + var rss serialisable.ViewSnapshot + if err := s.ws.One("ID", resultSetId, &rss); err != nil { + if errors.Is(err, storm.ErrNotFound) { + return nil + } else { + return errors.Wrapf(err, "cannot get snapshot with ID %v", resultSetId) + } + } + + if err := s.ws.DeleteStruct(&rss); err != nil { + return errors.Wrap(err, "cannot delete snapshot") + } + return nil +} diff --git a/internal/dynamo-browse/services/workspaces/iface.go b/internal/dynamo-browse/services/workspaces/iface.go new file mode 100644 index 0000000..75a151a --- /dev/null +++ b/internal/dynamo-browse/services/workspaces/iface.go @@ -0,0 +1,10 @@ +package workspaces + +import "github.com/lmika/audax/internal/dynamo-browse/models/serialisable" + +type ViewSnapshotStore interface { + Save(rs *serialisable.ViewSnapshot) error + SetAsHead(resultSetId int64) error + Head() (*serialisable.ViewSnapshot, error) + Remove(resultSetId int64) error +} diff --git a/internal/dynamo-browse/services/workspaces/service.go b/internal/dynamo-browse/services/workspaces/service.go new file mode 100644 index 0000000..34854dc --- /dev/null +++ b/internal/dynamo-browse/services/workspaces/service.go @@ -0,0 +1,69 @@ +package workspaces + +import ( + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/serialisable" + "github.com/pkg/errors" + "time" +) + +type ViewSnapshotService struct { + store ViewSnapshotStore +} + +func NewService(store ViewSnapshotStore) *ViewSnapshotService { + return &ViewSnapshotService{ + store: store, + } +} + +func (s *ViewSnapshotService) PushSnapshot(rs *models.ResultSet, filter string) error { + newSnapshot := &serialisable.ViewSnapshot{ + Time: time.Now(), + TableName: rs.TableInfo.Name, + } + if q := rs.Query; q != nil { + newSnapshot.Query = q.String() + } + newSnapshot.Filter = filter + + if head, err := s.store.Head(); head != nil { + newSnapshot.BackLink = head.ID + } else if err != nil { + return errors.Wrap(err, "cannot get head result set") + } + + if err := s.store.Save(newSnapshot); err != nil { + return errors.Wrap(err, "cannot save snapshot") + } + if err := s.store.SetAsHead(newSnapshot.ID); err != nil { + return errors.Wrap(err, "cannot set new snapshot as head") + } + + return nil +} + +func (s *ViewSnapshotService) PopSnapshot() (*serialisable.ViewSnapshot, error) { + vs, err := s.store.Head() + if err != nil { + return nil, errors.Wrap(err, "cannot get snapshot head") + } else if vs == nil || vs.BackLink == 0 { + return nil, nil + } + + if err := s.store.SetAsHead(vs.BackLink); err != nil { + return nil, errors.Wrap(err, "cannot set new head") + } + if err := s.store.Remove(vs.ID); err != nil { + return nil, errors.Wrap(err, "cannot remove old ID") + } + + vs, err = s.store.Head() + if err != nil { + return nil, errors.Wrap(err, "cannot get snapshot head") + } else if vs == nil || vs.BackLink == 0 { + return nil, nil + } + + return vs, nil +} diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index 3f746f5..eaefd2f 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -149,6 +149,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.tableReadController.PromptForQuery() case "/": return m, m.tableReadController.Filter() + case "backspace": + return m, m.tableReadController.ViewBack() //case "e": // m.itemEdit.Visible() // return m, nil