diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 820abc0..aeaf4eb 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -162,9 +162,12 @@ func main() { commandController := commandctrl.NewCommandController(inputHistoryService, cmdpacks.StandardCommands{ - ReadController: tableReadController, - WriteController: tableWriteController, - ExportController: exportController, + TableService: tableService, + State: state, + ReadController: tableReadController, + WriteController: tableWriteController, + ExportController: exportController, + KeyBindingController: keyBindingController, }, ) commandController.AddCommandLookupExtension(scriptController) diff --git a/go.mod b/go.mod index 4f87cec..3678e7c 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/lmika/dynamo-browse -go 1.22 +go 1.24 -toolchain go1.22.0 +toolchain go1.24.0 require ( github.com/alecthomas/participle/v2 v2.1.1 @@ -117,5 +117,5 @@ require ( golang.org/x/text v0.9.0 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8 // indirect + ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 // indirect ) diff --git a/go.sum b/go.sum index f26fcfa..fdaef5d 100644 --- a/go.sum +++ b/go.sum @@ -438,3 +438,7 @@ ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281 h1:/M7phiv/0XVp3wKkOxEnGQysf8+R ucl.lmika.dev v0.0.0-20240504013531-0dc9fd3c3281/go.mod h1:T6V4jIUxlWvMTgn4J752VDHNA8iyVrEX6v98EvDj8G4= ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8 h1:vWttdW8GJWcTUQeJFbQHqCHJDLFWQ9nccUTx/lW2v8s= ucl.lmika.dev v0.0.0-20250306030053-ad6d002a22e8/go.mod h1:FMP2ncSu4UxfvB0iA2zlebwL+1UPCARdyYNOrmi86A4= +ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2 h1:cvguOoQ0HVgLKbHH17ZHvAUFht6HXApLi0o8JOdaaNU= +ucl.lmika.dev v0.0.0-20250515115457-27b6cc0b92e2/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= +ucl.lmika.dev v0.0.0-20250517003439-109be33d1495 h1:r46r+7T59Drm+in7TEWKCZfFYIM0ZyZ26QjHAbj8Lto= +ucl.lmika.dev v0.0.0-20250517003439-109be33d1495/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go new file mode 100644 index 0000000..a748899 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -0,0 +1,123 @@ +package cmdpacks + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" + "github.com/pkg/errors" + "ucl.lmika.dev/repl" + "ucl.lmika.dev/ucl" +) + +type rsModule struct { + tableService *tables.Service + state *controllers.State +} + +var rsNewDoc = repl.Doc{ + Brief: "Creates a new, empty result set", +} + +func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (any, error) { + return &ResultSetProxy{ + RS: &models.ResultSet{}, + }, nil +} + +var rsQueryDoc = repl.Doc{ + Brief: "Runs a query and returns the results as a result-set", + Usage: "QUERY [ARGS] [-table NAME]", + Args: []repl.ArgDoc{ + {Name: "query", Brief: "Query expression to run"}, + {Name: "args", Brief: "Hash of argument values to substitute into the query"}, + {Name: "-table", Brief: "Optional table name to use for the query"}, + }, + Detailed: ` + If no table is specified, then the value of @table will be used. If this is unavailable, + the command will return an error. + `, +} + +func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) { + var expr string + if err := args.Bind(&expr); err != nil { + return nil, err + } + + q, err := queryexpr.Parse(expr) + if err != nil { + return nil, err + } + + if args.NArgs() > 0 { + var queryArgs ucl.Hashable + if err := args.Bind(&queryArgs); err != nil { + return nil, err + } + + queryNames := map[string]string{} + queryValues := map[string]types.AttributeValue{} + queryArgs.Each(func(k string, v ucl.Object) error { + if v == nil { + return nil + } + + queryNames[k] = v.String() + + switch v.(type) { + case ucl.StringObject: + queryValues[k] = &types.AttributeValueMemberS{Value: v.String()} + case ucl.IntObject: + queryValues[k] = &types.AttributeValueMemberN{Value: v.String()} + // TODO: other types + } + return nil + }) + + q = q.WithNameParams(queryNames).WithValueParams(queryValues) + } + + var tableInfo *models.TableInfo + if args.HasSwitch("table") { + var tblName string + if err := args.BindSwitch("table", &tblName); err != nil { + return nil, err + } + + tableInfo, err = rs.tableService.Describe(ctx, tblName) + if err != nil { + return nil, err + } + } else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil { + tableInfo = currRs.TableInfo + } else { + return nil, errors.New("no table specified") + } + + newResultSet, err := rs.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil) + if err != nil { + return nil, err + } + + return &ResultSetProxy{ + RS: newResultSet, + }, nil +} + +func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module { + m := &rsModule{ + tableService: tableService, + state: state, + } + + return ucl.Module{ + Name: "rs", + Builtins: map[string]ucl.BuiltinHandler{ + "new": m.rsNew, + "query": m.rsQuery, + }, + } +} diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go new file mode 100644 index 0000000..113bd46 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go @@ -0,0 +1,80 @@ +package cmdpacks_test + +import ( + "fmt" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestModRS_New(t *testing.T) { + svc := newService(t) + + rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:new`) + + assert.NoError(t, err) + assert.IsType(t, rsProxy, &cmdpacks.ResultSetProxy{}) +} + +func TestModRS_Query(t *testing.T) { + tests := []struct { + descr string + cmd string + wantRows []int + }{ + { + descr: "query with pk 1", + cmd: `rs:query 'pk="abc"' -table service-test-data`, + wantRows: []int{0, 1}, + }, + { + descr: "query with pk 2", + cmd: `rs:query 'pk="bbb"' -table service-test-data`, + wantRows: []int{2}, + }, + { + descr: "query with sk 1", + cmd: `rs:query 'sk="222"' -table service-test-data`, + wantRows: []int{1}, + }, + { + descr: "query with args 1", + cmd: `rs:query 'pk=$v' [v:'abc'] -table service-test-data`, + wantRows: []int{0, 1}, + }, + { + descr: "query with args 2", + cmd: `rs:query ':k=$v' [k:'pk' v:'abc'] -table service-test-data`, + wantRows: []int{0, 1}, + }, + { + descr: "query with args 3", + cmd: `rs:query ':k=$v' [k:'beta' v:1231] -table service-test-data`, + wantRows: []int{1}, + }, + { + descr: "query with args with no table set", + cmd: `rs:query ':k=$v' [k:'beta' v:1231]`, + wantRows: []int{1}, + }, + } + for _, tt := range tests { + t.Run(tt.descr, func(t *testing.T) { + svc := newService(t) + + res, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd) + assert.NoError(t, err) + + rs := res.(*cmdpacks.ResultSetProxy).RS + assert.Len(t, rs.Items(), len(tt.wantRows)) + + for i, rowIndex := range tt.wantRows { + for key, want := range testData[0].Data[rowIndex] { + have, ok := rs.Items()[i].AttributeValueAsString(key) + assert.True(t, ok) + assert.Equal(t, fmt.Sprint(want), have) + } + } + }) + } +} diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go new file mode 100644 index 0000000..b80aab2 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -0,0 +1,7 @@ +package cmdpacks + +import "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + +type ResultSetProxy struct { + RS *models.ResultSet +} diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go index 63cbcfa..21756a3 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -6,12 +6,15 @@ import ( "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" "github.com/pkg/errors" "ucl.lmika.dev/repl" "ucl.lmika.dev/ucl" ) type StandardCommands struct { + TableService *tables.Service + State *controllers.State ReadController *controllers.TableReadController WriteController *controllers.TableWriteController ExportController *controllers.ExportController @@ -358,6 +361,12 @@ func (sc StandardCommands) cmdRebind(ctx context.Context, args ucl.CallArgs) (an return nil, nil } +func (sc StandardCommands) InstOptions() []ucl.InstOption { + return []ucl.InstOption{ + ucl.WithModule(moduleRS(sc.TableService, sc.State)), + } +} + func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { ucl.SetBuiltin("quit", sc.cmdQuit) ucl.SetBuiltin("table", sc.cmdTable) diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go new file mode 100644 index 0000000..48dd158 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go @@ -0,0 +1,143 @@ +package cmdpacks_test + +import ( + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/inputhistorystore" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/settingstore" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/workspacestore" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/inputhistory" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot" + "github.com/lmika/dynamo-browse/test/testdynamo" + "github.com/lmika/dynamo-browse/test/testworkspace" + bus "github.com/lmika/events" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestStdCmds_Mark(t *testing.T) { + tests := []struct { + descr string + cmd string + wantMarks []bool + }{ + {descr: "mark default", cmd: "mark", wantMarks: []bool{true, true, true}}, + {descr: "mark all", cmd: "mark all", wantMarks: []bool{true, true, true}}, + {descr: "mark none", cmd: "mark none", wantMarks: []bool{false, false, false}}, + {descr: "mark where", cmd: `mark -where 'sk="222"'`, wantMarks: []bool{false, true, false}}, + {descr: "mark toggle", cmd: "mark ; mark toggle", wantMarks: []bool{false, false, false}}, + } + + for _, tt := range tests { + t.Run(tt.descr, func(t *testing.T) { + svc := newService(t) + + _, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd) + assert.NoError(t, err) + + for i, want := range tt.wantMarks { + assert.Equal(t, want, svc.State.ResultSet().Marked(i)) + } + }) + } +} + +type services struct { + CommandController *commandctrl.CommandController + SelItemIndex int + + State *controllers.State +} + +func newService(t *testing.T) *services { + ws := testworkspace.New(t) + + resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws) + settingStore := settingstore.New(ws) + inputHistoryStore := inputhistorystore.NewInputHistoryStore(ws) + + workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) + itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer()) + inputHistoryService := inputhistory.New(inputHistoryStore) + + client := testdynamo.SetupTestTable(t, testData) + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider, settingStore) + eventBus := bus.New() + + state := controllers.NewState() + jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true) + readController := controllers.NewTableReadController( + state, + service, + workspaceService, + itemRendererService, + jobsController, + inputHistoryService, + eventBus, + pasteboardprovider.NilProvider{}, + nil, + "service-test-data", + ) + writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) + settingsController := controllers.NewSettingsController(settingStore, eventBus) + columnsController := controllers.NewColumnsController(readController, eventBus) + exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{}) + + _ = settingsController + commandController := commandctrl.NewCommandController(inputHistoryService, + cmdpacks.StandardCommands{ + State: state, + TableService: service, + ReadController: readController, + WriteController: writeController, + ExportController: exportController, + }, + ) + + s := &services{ + State: state, + CommandController: commandController, + } + + commandController.SetUIStateProvider(s) + readController.Init() + + return s +} + +func (s *services) SelectedItemIndex() int { + return s.SelItemIndex +} + +var testData = []testdynamo.TestData{ + { + TableName: "service-test-data", + Data: []map[string]interface{}{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, + }, + }, +} diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 99bec81..79847f0 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -43,11 +43,17 @@ func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) *Co msgChan: make(chan tea.Msg), interactive: true, } - cc.uclInst = ucl.New( + + options := []ucl.InstOption{ ucl.WithOut(ucl.LineHandler(cc.printLine)), ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.FS(nil)), - ) + } + for _, pkg := range pkgs { + options = append(options, pkg.InstOptions()...) + } + + cc.uclInst = ucl.New(options...) for _, pkg := range pkgs { pkg.ConfigureUCL(cc.uclInst) @@ -126,26 +132,20 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms return events.Error(errors.New("command currently running")) } - /* - res, err := c.uclInst.Eval(context.Background(), commandInput) - if err != nil { - return events.Error(err) - } - - if teaMsg, ok := res.(teaMsgWrapper); ok { - return teaMsg.msg - } - */ return nil } +func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) { + return c.uclInst.Eval(ctx, commandInput) +} + func (c *CommandController) cmdLooper() { ctx := context.WithValue(context.Background(), commandCtlKey, c) for { select { case cmdChan := <-c.cmdChan: - res, err := c.uclInst.Eval(ctx, cmdChan.cmd) + res, err := c.ExecuteAndWait(ctx, cmdChan.cmd) if err != nil { c.postMessage(events.Error(err)) } else if res != nil { diff --git a/internal/common/ui/commandctrl/packs.go b/internal/common/ui/commandctrl/packs.go index 76d0bd7..6f5dbc6 100644 --- a/internal/common/ui/commandctrl/packs.go +++ b/internal/common/ui/commandctrl/packs.go @@ -3,5 +3,6 @@ package commandctrl import "ucl.lmika.dev/ucl" type CommandPack interface { + InstOptions() []ucl.InstOption ConfigureUCL(ucl *ucl.Inst) } diff --git a/internal/dynamo-browse/models/queryexpr/types.go b/internal/dynamo-browse/models/queryexpr/types.go index 2e55c7a..011931e 100644 --- a/internal/dynamo-browse/models/queryexpr/types.go +++ b/internal/dynamo-browse/models/queryexpr/types.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" "math/big" "strconv" + "strings" ) type exprValue interface { @@ -62,6 +63,14 @@ func newExprValueFromAttributeValue(ev types.AttributeValue) (exprValue, error) case *types.AttributeValueMemberS: return stringExprValue(xVal.Value), nil case *types.AttributeValueMemberN: + if !strings.Contains(xVal.Value, ".") { + iVal, err := strconv.ParseInt(xVal.Value, 10, 64) + if err != nil { + return nil, err + } + return int64ExprValue(iVal), nil + } + xNumVal, _, err := big.ParseFloat(xVal.Value, 10, 63, big.ToNearestEven) if err != nil { return nil, err @@ -139,6 +148,32 @@ func (s int64ExprValue) typeName() string { return "N" } +type bigIntExprValue struct { + num *big.Int +} + +func (i bigIntExprValue) asGoValue() any { + return i.num +} + +func (i bigIntExprValue) asAttributeValue() types.AttributeValue { + return &types.AttributeValueMemberN{Value: i.num.String()} +} + +func (i bigIntExprValue) asInt() int64 { + return i.num.Int64() +} + +func (i bigIntExprValue) asBigFloat() *big.Float { + var f big.Float + f.SetInt64(i.num.Int64()) + return &f +} + +func (s bigIntExprValue) typeName() string { + return "N" +} + type bigNumExprValue struct { num *big.Float }