Added the "export -all" switch (#54)
Extended the "export" command with an "-all" flag. When included, all rows of the table matching the query will be exported to CSV.
This commit is contained in:
parent
20a9a8c758
commit
ed53173a1d
|
@ -108,7 +108,7 @@ func main() {
|
|||
tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, jobsController, inputHistoryService, eventBus, *flagTable)
|
||||
tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore)
|
||||
columnsController := controllers.NewColumnsController(eventBus)
|
||||
exportController := controllers.NewExportController(state, columnsController)
|
||||
exportController := controllers.NewExportController(state, tableService, jobsController, columnsController)
|
||||
settingsController := controllers.NewSettingsController(settingStore, eventBus)
|
||||
keyBindings := keybindings.Default()
|
||||
scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, settingsController, eventBus)
|
||||
|
|
|
@ -9,6 +9,14 @@ func All[T any](ts []T, predicate func(t T) bool) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func Generate[U any](from, to int, fn func(t int) U) []U {
|
||||
us := make([]U, to-from+1)
|
||||
for i := from; i <= to; i++ {
|
||||
us[i-from] = fn(i)
|
||||
}
|
||||
return us
|
||||
}
|
||||
|
||||
func Map[T, U any](ts []T, fn func(t T) U) []U {
|
||||
us := make([]U, len(ts))
|
||||
for i, t := range ts {
|
||||
|
|
|
@ -1,57 +1,85 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/dynamo-browse/internal/common/ui/events"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/attrutils"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs"
|
||||
"github.com/pkg/errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ExportController struct {
|
||||
state *State
|
||||
columns *ColumnsController
|
||||
state *State
|
||||
tableService TableReadService
|
||||
jobController *JobsController
|
||||
columns *ColumnsController
|
||||
}
|
||||
|
||||
func NewExportController(state *State, columns *ColumnsController) *ExportController {
|
||||
return &ExportController{state, columns}
|
||||
func NewExportController(state *State, tableService TableReadService, jobsController *JobsController, columns *ColumnsController) *ExportController {
|
||||
return &ExportController{state, tableService, jobsController, columns}
|
||||
}
|
||||
|
||||
func (c *ExportController) ExportCSV(filename string) tea.Msg {
|
||||
func (c *ExportController) ExportCSV(filename string, opts ExportOptions) 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], _ = attrutils.AttributeToString(col.Evaluator.EvaluateForItem(item))
|
||||
return NewJob(c.jobController, fmt.Sprintf("Exporting to %v…", filename), func(ctx context.Context) (int, error) {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "cannot export to '%v'", filename)
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
|
||||
}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return nil
|
||||
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 0, errors.Wrapf(err, "cannot export to '%v'", filename)
|
||||
}
|
||||
|
||||
totalRows := 0
|
||||
row := make([]string, len(columns))
|
||||
for {
|
||||
for _, item := range resultSet.Items() {
|
||||
for i, col := range columns {
|
||||
row[i], _ = attrutils.AttributeToString(col.Evaluator.EvaluateForItem(item))
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return 0, errors.Wrapf(err, "cannot export to '%v'", filename)
|
||||
}
|
||||
}
|
||||
totalRows += len(resultSet.Items())
|
||||
|
||||
if !opts.AllResults || !resultSet.HasNextPage() {
|
||||
break
|
||||
}
|
||||
|
||||
jobs.PostUpdate(ctx, fmt.Sprintf("exported %d items", totalRows))
|
||||
resultSet, err = c.tableService.NextPage(ctx, resultSet)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "cannot get next page while exporting to '%v'", filename)
|
||||
}
|
||||
}
|
||||
|
||||
return totalRows, nil
|
||||
}).OnDone(func(rows int) tea.Msg {
|
||||
return events.StatusMsg(applyToN("Exported ", rows, "item", "items", " to "+filename))
|
||||
}).Submit()
|
||||
}
|
||||
|
||||
type ExportOptions struct {
|
||||
// AllResults returns all results from the table
|
||||
AllResults bool
|
||||
}
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
package controllers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/lmika/dynamo-browse/internal/common/sliceutils"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"strings"
|
||||
|
@ -14,7 +17,7 @@ func TestExportController_ExportCSV(t *testing.T) {
|
|||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{}))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
@ -27,13 +30,66 @@ func TestExportController_ExportCSV(t *testing.T) {
|
|||
}, ""))
|
||||
})
|
||||
|
||||
t.Run("should export all pages of the results", func(t *testing.T) {
|
||||
pageLimits := []int{5, 10, 50}
|
||||
|
||||
for _, pageLimit := range pageLimits {
|
||||
t.Run(fmt.Sprintf("page size %d", pageLimit), func(t *testing.T) {
|
||||
t.Run("all results", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "count-to-30", defaultLimit: 5})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
expected := append([]string{
|
||||
"pk,sk,num\n",
|
||||
}, sliceutils.Generate(1, 30, func(i int) string {
|
||||
return fmt.Sprintf("NUM,NUM#%02d,%d\n", i, i)
|
||||
})...)
|
||||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{
|
||||
AllResults: true,
|
||||
}))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, strings.Join(expected, ""), string(bts))
|
||||
})
|
||||
|
||||
t.Run("with query", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "count-to-30", defaultLimit: 5})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
expected := append([]string{
|
||||
"pk,sk,num\n",
|
||||
}, sliceutils.Generate(1, 15, func(i int) string {
|
||||
return fmt.Sprintf("NUM,NUM#%02d,%d\n", i, i)
|
||||
})...)
|
||||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommandWithPrompt(t, srv.readController.PromptForQuery(), "num<=15")
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{
|
||||
AllResults: true,
|
||||
}))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, strings.Join(expected, ""), string(bts))
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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))
|
||||
invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{}))
|
||||
})
|
||||
|
||||
t.Run("should honour new columns in CSV file", func(t *testing.T) {
|
||||
|
@ -48,7 +104,7 @@ func TestExportController_ExportCSV(t *testing.T) {
|
|||
invokeCommandWithPrompt(t, srv.columnsController.AddColumn(1), "address.street")
|
||||
invokeCommand(t, srv.columnsController.ShiftColumnLeft(1))
|
||||
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{}))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
@ -71,7 +127,7 @@ func TestExportController_ExportCSV(t *testing.T) {
|
|||
invokeCommand(t, srv.columnsController.ToggleVisible(1))
|
||||
invokeCommand(t, srv.columnsController.ToggleVisible(2))
|
||||
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{}))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
|
|
@ -51,6 +51,9 @@ func (jb JobBuilder[T]) executeJob(ctx context.Context) tea.Msg {
|
|||
if jb.onEither != nil {
|
||||
return jb.onEither(res, err)
|
||||
} else if err == nil {
|
||||
if jb.onDone == nil {
|
||||
return nil
|
||||
}
|
||||
return jb.onDone(res)
|
||||
} else {
|
||||
if jb.onErr != nil {
|
||||
|
|
|
@ -377,7 +377,7 @@ func (c *TableReadController) NextPage() tea.Msg {
|
|||
resultSet := c.state.ResultSet()
|
||||
if resultSet == nil {
|
||||
return events.StatusMsg("Result-set is nil")
|
||||
} else if resultSet.LastEvaluatedKey == nil {
|
||||
} else if !resultSet.HasNextPage() {
|
||||
return events.StatusMsg("No more results")
|
||||
}
|
||||
currentFilter := c.state.filter
|
||||
|
|
|
@ -89,7 +89,7 @@ func TestTableReadController_Query(t *testing.T) {
|
|||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommandWithPrompts(t, srv.readController.PromptForQuery(), `pk ^= "abc"`)
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{}))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
@ -107,7 +107,7 @@ func TestTableReadController_Query(t *testing.T) {
|
|||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommandWithPrompts(t, srv.readController.PromptForQuery(), `alpha = "This is some value"`)
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{}))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
@ -124,7 +124,7 @@ func TestTableReadController_Query(t *testing.T) {
|
|||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommandExpectingError(t, srv.readController.Init())
|
||||
invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile))
|
||||
invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile, controllers.ExportOptions{}))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -621,7 +621,7 @@ func newService(t *testing.T, cfg serviceConfig) *services {
|
|||
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
|
||||
settingsController := controllers.NewSettingsController(settingStore, eventBus)
|
||||
columnsController := controllers.NewColumnsController(eventBus)
|
||||
exportController := controllers.NewExportController(state, columnsController)
|
||||
exportController := controllers.NewExportController(state, service, jobsController, columnsController)
|
||||
scriptController := controllers.NewScriptController(scriptService, readController, settingsController, eventBus)
|
||||
|
||||
commandController := commandctrl.NewCommandController(inputHistoryService)
|
||||
|
|
|
@ -135,3 +135,7 @@ func (rs *ResultSet) RefreshColumns() {
|
|||
|
||||
rs.columns = columns
|
||||
}
|
||||
|
||||
func (rs *ResultSet) HasNextPage() bool {
|
||||
return rs.LastEvaluatedKey != nil
|
||||
}
|
||||
|
|
|
@ -102,7 +102,14 @@ func NewModel(
|
|||
if len(args) == 0 {
|
||||
return events.Error(errors.New("expected filename"))
|
||||
}
|
||||
return exportController.ExportCSV(args[0])
|
||||
|
||||
opts := controllers.ExportOptions{}
|
||||
if len(args) == 2 && args[0] == "-all" {
|
||||
opts.AllResults = true
|
||||
args = args[1:]
|
||||
}
|
||||
|
||||
return exportController.ExportCSV(args[0], opts)
|
||||
},
|
||||
"mark": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
|
||||
var markOp = controllers.MarkOpMark
|
||||
|
|
Loading…
Reference in a new issue