diff --git a/.gitignore b/.gitignore index 2b59837..b14c548 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ debug.log -.DS_store -.idea diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 3c5c4bf..8adcce4 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -4,11 +4,9 @@ import ( "context" "flag" "fmt" - "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" "log" "net" "os" - "strings" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/dynamodb" @@ -28,6 +26,7 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs" keybindings_service "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" "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/internal/dynamo-browse/ui" @@ -45,7 +44,6 @@ func main() { var flagDefaultLimit = flag.Int("default-limit", 0, "default limit for queries and scans") var flagWorkspace = flag.String("w", "", "workspace file") var flagQuery = flag.String("q", "", "run query") - var flagExtDir = flag.String("ext-dir", "$HOME/.config/dynamo-browse/ext:$HOME/.config/dynamo-browse/.", "directory to search for extensions") flag.Parse() ctx := context.Background() @@ -106,6 +104,7 @@ func main() { tableService := tables.NewService(dynamoProvider, settingStore) workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) + scriptManagerService := scriptmanager.New() jobsService := jobs.NewService(eventBus) inputHistoryService := inputhistory.New(inputHistoryStore) @@ -120,6 +119,7 @@ func main() { inputHistoryService, eventBus, pasteboardProvider, + scriptManagerService, *flagTable, ) tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) @@ -127,6 +127,7 @@ func main() { exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider) settingsController := controllers.NewSettingsController(settingStore, eventBus) keyBindings := keybindings.Default() + scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus) if *flagQuery != "" { if *flagTable == "" { @@ -156,23 +157,10 @@ func main() { } keyBindingService := keybindings_service.NewService(keyBindings) - keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil) + keyBindingController := controllers.NewKeyBindingController(keyBindingService, scriptController) - stdCommands := cmdpacks.NewStandardCommands( - tableService, - state, - tableReadController, - tableWriteController, - exportController, - keyBindingController, - pasteboardProvider, - settingsController, - ) - - commandController, err := commandctrl.NewCommandController(inputHistoryService, stdCommands) - if err != nil { - cli.Fatalf("cannot setup command controller: %v", err) - } + commandController := commandctrl.NewCommandController(inputHistoryService) + commandController.AddCommandLookupExtension(scriptController) commandController.SetCommandCompletionProvider(columnsController) model := ui.NewModel( @@ -184,12 +172,12 @@ func main() { jobsController, itemRendererService, commandController, + scriptController, eventBus, keyBindingController, pasteboardProvider, keyBindings, ) - commandController.SetUIStateProvider(&model) // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. osstyle.DetectCurrentScheme() @@ -197,12 +185,8 @@ func main() { p := tea.NewProgram(model, tea.WithAltScreen()) jobsController.SetMessageSender(p.Send) - - if err := commandController.LoadExtensions(context.Background(), strings.Split(*flagExtDir, string(os.PathListSeparator))); err != nil { - fmt.Printf("Unable to load extensions: %v", err) - } - - go commandController.StartMessageSender(p.Send) + scriptController.Init() + scriptController.SetMessageSender(p.Send) log.Println("launching") if err := p.Start(); err != nil { diff --git a/go.mod b/go.mod index 18738aa..4912f4c 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/lmika/dynamo-browse -go 1.24 +go 1.22 -toolchain go1.24.0 +toolchain go1.22.0 require ( - github.com/alecthomas/participle/v2 v2.1.1 + github.com/alecthomas/participle/v2 v2.0.0-beta.5 github.com/asdine/storm v2.1.2+incompatible github.com/aws/aws-sdk-go-v2 v1.18.1 github.com/aws/aws-sdk-go-v2/config v1.18.27 @@ -20,16 +20,16 @@ require ( github.com/charmbracelet/lipgloss v0.6.0 github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 - github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f + github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe github.com/mattn/go-runewidth v0.0.14 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 github.com/muesli/reflow v0.3.0 github.com/pkg/errors v0.9.1 - github.com/stretchr/testify v1.9.0 + github.com/risor-io/risor v1.4.0 + github.com/stretchr/testify v1.8.4 golang.design/x/clipboard v0.6.2 golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a - ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e ) require ( @@ -55,7 +55,7 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mattn/go-isatty v0.0.17 // indirect @@ -64,8 +64,8 @@ require ( github.com/muesli/termenv v0.13.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.2 // indirect - github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect go.etcd.io/bbolt v1.3.6 // indirect golang.org/x/exp/shiny v0.0.0-20230213192124-5e25df0256eb // indirect diff --git a/go.sum b/go.sum index 462dd4c..cc4b77a 100644 --- a/go.sum +++ b/go.sum @@ -3,12 +3,12 @@ github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879 h1:M5ptEKnqKqpFTKbe+p5zEf3ro1deJ6opUz5j3g3/ErQ= github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= -github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= -github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= -github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= -github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/assert/v2 v2.0.3 h1:WKqJODfOiQG0nEJKFKzDIG3E29CN2/4zR9XGJzKIkbg= +github.com/alecthomas/assert/v2 v2.0.3/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= +github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35a3WghArZ/Hbebrjo= +github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM= +github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= +github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/asdine/storm v2.1.2+incompatible h1:dczuIkyqwY2LrtXPz8ixMrU/OFgZp71kbKTHGrXYt/Q= github.com/asdine/storm v2.1.2+incompatible/go.mod h1:RarYDc9hq1UPLImuiXK3BIWPJLdIygvV3PsInK0FbVQ= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -105,8 +105,8 @@ github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGy github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM= github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 h1:dtMPRNoDqDnnP3HgOvYhswcJVSqdISkYlCtGOjTqg6Q= github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg= -github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f h1:tz68Lhc1oR15HVz69IGbtdukdH0x70kBDEvvj5pTXyE= -github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f/go.mod h1:zHQvhjGXRro/Xp2C9dbC+ZUpE0gL4GYW75x1lk7hwzI= +github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI= +github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ= github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18= github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe/go.mod h1:qpdOkLougV5Yry4Px9f1w1pNMavcr6Z67VW5Ro+vW5I= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -139,23 +139,28 @@ github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/risor-io/risor v1.4.0 h1:G17pWgq+N06jWvnaJVwos89tC5C4VMjqwGYRrTWleRM= +github.com/risor-io/risor v1.4.0/go.mod h1:+s/FeK0CdsTCCNZsHSp8EJa3u3mMrhqtNGLCv/GcW8Y= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -245,7 +250,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e h1:N+HzQUunDUvdjAzbSDtHQZVZ1k+XHbVgbNwmc+EKmlQ= -ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY= diff --git a/internal/common/ui/commandctrl/cmdpacks/modopt.go b/internal/common/ui/commandctrl/cmdpacks/modopt.go deleted file mode 100644 index 398a86b..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/modopt.go +++ /dev/null @@ -1,47 +0,0 @@ -package cmdpacks - -import ( - "context" - "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" - "ucl.lmika.dev/ucl" -) - -type optModule struct { - settingsController *controllers.SettingsController -} - -func (m optModule) pbSet(ctx context.Context, args ucl.CallArgs) (any, error) { - var ( - name string - newVale string - ) - - if args.NArgs() == 1 { - if err := args.Bind(&name); err != nil { - return nil, err - } - } else { - if err := args.Bind(&name, &newVale); err != nil { - return nil, err - } - } - - commandctrl.PostMsg(ctx, m.settingsController.SetSetting(name, newVale)) - return nil, nil -} - -func moduleOpt( - settingsController *controllers.SettingsController, -) ucl.Module { - m := &optModule{ - settingsController: settingsController, - } - - return ucl.Module{ - Name: "opt", - Builtins: map[string]ucl.BuiltinHandler{ - "set": m.pbSet, - }, - } -} diff --git a/internal/common/ui/commandctrl/cmdpacks/modpb.go b/internal/common/ui/commandctrl/cmdpacks/modpb.go deleted file mode 100644 index 2a27d6e..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/modpb.go +++ /dev/null @@ -1,46 +0,0 @@ -package cmdpacks - -import ( - "context" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/services" - "ucl.lmika.dev/ucl" -) - -type pbModule struct { - pasteboardProvider services.PasteboardProvider -} - -func (m pbModule) pbGet(ctx context.Context, args ucl.CallArgs) (any, error) { - s, ok := m.pasteboardProvider.ReadText() - if !ok { - return "", nil - } - return s, nil -} - -func (m pbModule) pbPut(ctx context.Context, args ucl.CallArgs) (any, error) { - var s string - if err := args.Bind(&s); err != nil { - return nil, err - } - if err := m.pasteboardProvider.WriteText([]byte(s)); err != nil { - return nil, err - } - return s, nil -} - -func modulePB( - pasteboardProvider services.PasteboardProvider, -) ucl.Module { - m := &pbModule{ - pasteboardProvider: pasteboardProvider, - } - - return ucl.Module{ - Name: "pb", - Builtins: map[string]ucl.BuiltinHandler{ - "get": m.pbGet, - "put": m.pbPut, - }, - } -} diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go deleted file mode 100644 index 148723c..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/modrs.go +++ /dev/null @@ -1,308 +0,0 @@ -package cmdpacks - -import ( - "context" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "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/models/queryexpr" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" - "github.com/pkg/errors" - "time" - "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", - Usage: "[-table NAME]", - Detailed: ` - The result set assumes the details of the current table. If no table is specified, - the command will return an error. - `, -} - -func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (_ any, err error) { - 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") - } - - return newResultSetProxy(&models.ResultSet{ - TableInfo: tableInfo, - Created: time.Now(), - }), 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 parseQuery( - ctx context.Context, - args ucl.CallArgs, - currentRS *models.ResultSet, - tablesService *tables.Service, -) (*queryexpr.QueryExpr, *models.TableInfo, error) { - var expr string - if err := args.Bind(&expr); err != nil { - return nil, nil, err - } - - q, err := queryexpr.Parse(expr) - if err != nil { - return nil, nil, err - } - - if args.NArgs() > 0 { - var queryArgs ucl.Hashable - if err := args.Bind(&queryArgs); err != nil { - return nil, 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, nil, err - } - - tableInfo, err = tablesService.Describe(ctx, tblName) - if err != nil { - return nil, nil, err - } - } else if currentRS != nil && currentRS.TableInfo != nil { - tableInfo = currentRS.TableInfo - } else { - return nil, nil, errors.New("no table specified") - } - - return q, tableInfo, nil -} - -func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) { - q, tableInfo, err := parseQuery(ctx, args, rs.state.ResultSet(), rs.tableService) - if err != nil { - return nil, err - } - - newResultSet, err := rs.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil) - if err != nil { - return nil, err - } - - return newResultSetProxy(newResultSet), nil -} - -var rsScanDoc = repl.Doc{ - Brief: "Performs a scan of the table and returns the results as a result-set", - Usage: "[-table NAME]", - Args: []repl.ArgDoc{ - {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) rsScan(ctx context.Context, args ucl.CallArgs) (_ any, err error) { - 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.Scan(context.Background(), tableInfo) - if err != nil { - return nil, err - } - - return newResultSetProxy(newResultSet), nil -} - -func (rs *rsModule) rsFilter(ctx context.Context, args ucl.CallArgs) (any, error) { - var ( - rsProxy SimpleProxy[*models.ResultSet] - filter string - ) - - if err := args.Bind(&rsProxy, &filter); err != nil { - return nil, err - } - - newResultSet := rs.tableService.Filter(rsProxy.ProxyValue(), filter) - return newResultSetProxy(newResultSet), nil -} - -var rsNextPageDoc = repl.Doc{ - Brief: "Returns the next page of the passed in result-set", - Usage: "RESULT_SET", - Args: []repl.ArgDoc{ - {Name: "result-set", Brief: "Result set to fetch the next page of"}, - }, - Detailed: ` - If no next page exists, the command will return nil. - `, -} - -func (rs *rsModule) rsNextPage(ctx context.Context, args ucl.CallArgs) (_ any, err error) { - var rsProxy SimpleProxy[*models.ResultSet] - - if err := args.Bind(&rsProxy); err != nil { - return nil, err - } - - if !rsProxy.value.HasNextPage() { - return nil, nil - } - - nextPage, err := rs.tableService.NextPage(ctx, rsProxy.value) - if err != nil { - return nil, err - } - - return newResultSetProxy(nextPage), nil -} - -func (rs *rsModule) rsUnion(ctx context.Context, args ucl.CallArgs) (_ any, err error) { - var rsProxy1, rsProxy2 SimpleProxy[*models.ResultSet] - - if err := args.Bind(&rsProxy1, &rsProxy2); err != nil { - return nil, err - } - - return newResultSetProxy(rsProxy1.ProxyValue().MergeWith(rsProxy2.ProxyValue())), nil -} - -func (rs *rsModule) rsSet(ctx context.Context, args ucl.CallArgs) (_ any, err error) { - var ( - item itemProxy - expr string - val ucl.Object - ) - - if err := args.Bind(&item, &expr, &val); err != nil { - return nil, err - } - - q, err := queryexpr.Parse(expr) - if err != nil { - return nil, err - } - - // TEMP: attribute is always S - if err := q.SetEvalItem(item.item, &types.AttributeValueMemberS{Value: val.String()}); err != nil { - return nil, err - } - item.resultSet.SetDirty(item.idx, true) - commandctrl.QueueRefresh(ctx) - - return item, nil -} - -func (rs *rsModule) rsDel(ctx context.Context, args ucl.CallArgs) (_ any, err error) { - var ( - item itemProxy - expr string - ) - - if err := args.Bind(&item, &expr); err != nil { - return nil, err - } - - q, err := queryexpr.Parse(expr) - if err != nil { - return nil, err - } - - if err := q.DeleteAttribute(item.item); err != nil { - return nil, err - } - item.resultSet.SetDirty(item.idx, true) - commandctrl.QueueRefresh(ctx) - - return item, 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, - "scan": m.rsScan, - "filter": m.rsFilter, - "next-page": m.rsNextPage, - "union": m.rsUnion, - "set": m.rsSet, - "del": m.rsDel, - }, - } -} diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go deleted file mode 100644 index 878ad3b..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package cmdpacks_test - -import ( - "fmt" - "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" - "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.SimpleProxy[*models.ResultSet]{}) -} - -func TestModRS_NextPage(t *testing.T) { - t.Run("multiple pages", func(t *testing.T) { - svc := newService(t, withDataGenerator(largeTestData), withTable("large-table"), withDefaultLimit(20)) - - hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`) - assert.NoError(t, err) - assert.True(t, hasNextPage.(bool)) - - // Page 2 - rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`) - - assert.NoError(t, err) - assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy) - assert.Equal(t, 20, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items())) - - hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset).HasNextPage`) - assert.NoError(t, err) - assert.True(t, hasNextPage.(bool)) - - // Page 3 - rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset | rs:next-page`) - - assert.NoError(t, err) - assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy) - assert.Equal(t, 10, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items())) - - hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset | rs:next-page).HasNextPage`) - assert.NoError(t, err) - assert.False(t, hasNextPage.(bool)) - - // Last page - rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page (rs:next-page @resultset | rs:next-page)`) - assert.NoError(t, err) - assert.Nil(t, rsProxy) - }) - - t.Run("only one page", func(t *testing.T) { - svc := newService(t) - - hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`) - assert.NoError(t, err) - assert.False(t, hasNextPage.(bool)) - - rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`) - assert.NoError(t, err) - assert.Nil(t, rsProxy) - }) -} - -func TestModRS_Union(t *testing.T) { - svc := newService(t, withDefaultLimit(2)) - - rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), ` - $mr = rs:union @resultset (rs:next-page @resultset) - - assert (eq (len $mr.Items) 3) "expected len == 3" - assert (eq $mr.Items.(0).pk "abc") "expected 0.pk" - assert (eq $mr.Items.(0).sk "111") "expected 0.sk" - assert (eq $mr.Items.(1).pk "abc") "expected 1.pk" - assert (eq $mr.Items.(1).sk "222") "expected 1.sk" - assert (eq $mr.Items.(2).pk "bbb") "expected 2.pk" - assert (eq $mr.Items.(2).sk "131") "expected 2.sk" - - $mr - `) - - assert.NoError(t, err) - assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy) - - rs := rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue() - assert.Equal(t, 3, len(rs.Items())) -} - -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.SimpleProxy[*models.ResultSet]).ProxyValue() - assert.Len(t, rs.Items(), len(tt.wantRows)) - - for i, rowIndex := range tt.wantRows { - for key, want := range svc.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/modui.go b/internal/common/ui/commandctrl/cmdpacks/modui.go deleted file mode 100644 index 26253e6..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/modui.go +++ /dev/null @@ -1,210 +0,0 @@ -package cmdpacks - -import ( - "context" - tea "github.com/charmbracelet/bubbletea" - "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" - "github.com/lmika/dynamo-browse/internal/common/ui/events" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables" - "ucl.lmika.dev/ucl" -) - -type uiModule struct { - tableService *tables.Service - state *controllers.State - ckb *customKeyBinding - readController *controllers.TableReadController -} - -func (m *uiModule) uiCommand(ctx context.Context, args ucl.CallArgs) (any, error) { - var ( - name string - cmd ucl.Invokable - ) - if err := args.Bind(&name, &cmd); err != nil { - return nil, err - } - - invoker := commandctrl.GetInvoker(ctx) - invoker.Inst().SetBuiltinInvokable(name, cmd) - return nil, nil -} - -func (m *uiModule) uiPrompt(ctx context.Context, args ucl.CallArgs) (any, error) { - var prompt string - if err := args.Bind(&prompt); err != nil { - return nil, err - } - - resChan := make(chan string) - go func() { - commandctrl.PostMsg(ctx, events.PromptForInput(prompt, nil, func(value string) tea.Msg { - resChan <- value - return nil - })) - }() - - select { - case value := <-resChan: - return value, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -func (m *uiModule) uiConfirm(ctx context.Context, args ucl.CallArgs) (any, error) { - var prompt string - if err := args.Bind(&prompt); err != nil { - return nil, err - } - - resChan := make(chan bool) - go func() { - commandctrl.PostMsg(ctx, events.Confirm(prompt, func(value bool) tea.Msg { - resChan <- value - return nil - })) - }() - - select { - case value := <-resChan: - return value, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -func (m *uiModule) uiPromptTable(ctx context.Context, args ucl.CallArgs) (any, error) { - tables, err := m.tableService.ListTables(context.Background()) - if err != nil { - return nil, err - } - - resChan := make(chan string) - go func() { - commandctrl.PostMsg(ctx, controllers.PromptForTableMsg{ - Tables: tables, - OnSelected: func(tableName string) tea.Msg { - resChan <- tableName - return nil - }, - }) - }() - - select { - case value := <-resChan: - return value, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -func (m *uiModule) uiBind(ctx context.Context, args ucl.CallArgs) (any, error) { - var ( - bindName string - key string - inv ucl.Invokable - ) - - if args.NArgs() == 2 { - if err := args.Bind(&key, &inv); err != nil { - return nil, err - } - bindName = "custom." + key - } else { - if err := args.Bind(&bindName, &key, &inv); err != nil { - return nil, err - } - } - - invoker := commandctrl.GetInvoker(ctx) - - m.ckb.bindings[bindName] = func() tea.Msg { - return invoker.Invoke(inv, nil) - } - m.ckb.keyBindings[key] = bindName - return nil, nil -} - -func (m *uiModule) uiQuery(ctx context.Context, args ucl.CallArgs) (any, error) { - q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService) - if err != nil { - return nil, err - } - - commandctrl.PostMsg(ctx, m.readController.RunQuery(q, tableInfo)) - return nil, nil -} - -func (m *uiModule) uiFilter(ctx context.Context, args ucl.CallArgs) (any, error) { - var filter string - - if err := args.Bind(&filter); err != nil { - return nil, err - } - - commandctrl.PostMsg(ctx, m.readController.Filter(filter)) - return nil, nil -} - -func moduleUI( - tableService *tables.Service, - state *controllers.State, - readController *controllers.TableReadController, -) (ucl.Module, controllers.CustomKeyBindingSource) { - m := &uiModule{ - tableService: tableService, - state: state, - readController: readController, - ckb: &customKeyBinding{ - bindings: map[string]tea.Cmd{}, - keyBindings: map[string]string{}, - }, - } - - return ucl.Module{ - Name: "ui", - Builtins: map[string]ucl.BuiltinHandler{ - "command": m.uiCommand, - "prompt": m.uiPrompt, - "prompt-table": m.uiPromptTable, - "confirm": m.uiConfirm, - "query": m.uiQuery, - "filter": m.uiFilter, - "bind": m.uiBind, - }, - }, m.ckb -} - -type customKeyBinding struct { - bindings map[string]tea.Cmd - keyBindings map[string]string -} - -func (c *customKeyBinding) LookupBinding(theKey string) string { - return c.keyBindings[theKey] -} - -func (c *customKeyBinding) CustomKeyCommand(key string) tea.Cmd { - bindingName, ok := c.keyBindings[key] - if !ok { - return nil - } - - binding, ok := c.bindings[bindingName] - if !ok { - return nil - } - - return binding -} - -func (c *customKeyBinding) UnbindKey(key string) { - delete(c.keyBindings, key) -} - -func (c *customKeyBinding) Rebind(bindingName string, newKey string) error { - c.keyBindings[newKey] = bindingName - return nil -} diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go deleted file mode 100644 index 8dad249..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/proxy.go +++ /dev/null @@ -1,216 +0,0 @@ -package cmdpacks - -import ( - "fmt" - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" - "maps" - "strconv" - "ucl.lmika.dev/ucl" -) - -type proxyInfo[T comparable] struct { - fields map[string]func(t T) ucl.Object - lenFunc func(t T) int - strFunc func(t T) string -} - -type SimpleProxy[T comparable] struct { - value T - proxyInfo *proxyInfo[T] -} - -func (tp SimpleProxy[T]) ProxyValue() T { - return tp.value -} - -func (tp SimpleProxy[T]) String() string { - if tp.proxyInfo.strFunc != nil { - return tp.proxyInfo.strFunc(tp.value) - } - return fmt.Sprint(tp.value) -} - -func (tp SimpleProxy[T]) Truthy() bool { - var zeroT T - return tp.value != zeroT -} - -func (tp SimpleProxy[T]) Len() int { - if tp.proxyInfo.lenFunc != nil { - return tp.proxyInfo.lenFunc(tp.value) - } - return len(tp.proxyInfo.fields) -} - -func (tp SimpleProxy[T]) Value(k string) ucl.Object { - f, ok := tp.proxyInfo.fields[k] - if !ok { - return nil - } - return f(tp.value) -} - -func (tp SimpleProxy[T]) Each(fn func(k string, v ucl.Object) error) error { - for key := range maps.Keys(tp.proxyInfo.fields) { - if err := fn(key, tp.Value(key)); err != nil { - return err - } - } - return nil -} - -type simpleProxyList[T comparable] struct { - values []T - converter func(T) ucl.Object -} - -func newSimpleProxyList[T comparable](values []T, converter func(T) ucl.Object) simpleProxyList[T] { - return simpleProxyList[T]{values: values, converter: converter} -} - -func (tp simpleProxyList[T]) String() string { - return fmt.Sprint(tp.values) -} - -func (tp simpleProxyList[T]) Truthy() bool { - return len(tp.values) > 0 -} - -func (tp simpleProxyList[T]) Len() int { - return len(tp.values) -} - -func (tp simpleProxyList[T]) Index(k int) ucl.Object { - return tp.converter(tp.values[k]) -} - -func newResultSetProxy(rs *models.ResultSet) ucl.Object { - return SimpleProxy[*models.ResultSet]{value: rs, proxyInfo: resultSetProxyFields} -} - -var resultSetProxyFields = &proxyInfo[*models.ResultSet]{ - lenFunc: func(t *models.ResultSet) int { return len(t.Items()) }, - strFunc: func(t *models.ResultSet) string { - return fmt.Sprintf("ResultSet(%v:%d)", t.TableInfo.Name, len(t.Items())) - }, - fields: map[string]func(t *models.ResultSet) ucl.Object{ - "Table": func(t *models.ResultSet) ucl.Object { return newTableProxy(t.TableInfo) }, - "Items": func(t *models.ResultSet) ucl.Object { return resultSetItemsProxy{t} }, - "HasNextPage": func(t *models.ResultSet) ucl.Object { return ucl.BoolObject(t.HasNextPage()) }, - }, -} - -func newTableProxy(table *models.TableInfo) ucl.Object { - return SimpleProxy[*models.TableInfo]{value: table, proxyInfo: tableProxyFields} -} - -var tableProxyFields = &proxyInfo[*models.TableInfo]{ - strFunc: func(t *models.TableInfo) string { - return fmt.Sprintf("Table(%v)", t.Name) - }, - fields: map[string]func(t *models.TableInfo) ucl.Object{ - "Name": func(t *models.TableInfo) ucl.Object { return ucl.StringObject(t.Name) }, - "Keys": func(t *models.TableInfo) ucl.Object { return newKeyAttributeProxy(t.Keys) }, - "DefinedAttributes": func(t *models.TableInfo) ucl.Object { return ucl.StringListObject(t.DefinedAttributes) }, - "GSIs": func(t *models.TableInfo) ucl.Object { return newSimpleProxyList(t.GSIs, newGSIProxy) }, - }, -} - -func newKeyAttributeProxy(keyAttrs models.KeyAttribute) ucl.Object { - return SimpleProxy[models.KeyAttribute]{value: keyAttrs, proxyInfo: keyAttributeProxyFields} -} - -var keyAttributeProxyFields = &proxyInfo[models.KeyAttribute]{ - strFunc: func(t models.KeyAttribute) string { - return fmt.Sprintf("KeyAttribute(%v,%v)", t.PartitionKey, t.SortKey) - }, - fields: map[string]func(t models.KeyAttribute) ucl.Object{ - "PK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) }, - "SK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) }, - }, -} - -func newGSIProxy(gsi models.TableGSI) ucl.Object { - return SimpleProxy[models.TableGSI]{value: gsi, proxyInfo: gsiProxyFields} -} - -var gsiProxyFields = &proxyInfo[models.TableGSI]{ - strFunc: func(t models.TableGSI) string { - return fmt.Sprintf("TableGSI(%v,(%v,%v))", t.Name, t.Keys.PartitionKey, t.Keys.SortKey) - }, - fields: map[string]func(t models.TableGSI) ucl.Object{ - "Name": func(t models.TableGSI) ucl.Object { return ucl.StringObject(t.Name) }, - "Keys": func(t models.TableGSI) ucl.Object { return newKeyAttributeProxy(t.Keys) }, - }, -} - -type resultSetItemsProxy struct { - resultSet *models.ResultSet -} - -func (ip resultSetItemsProxy) String() string { - return "RSItem()" -} - -func (ip resultSetItemsProxy) Truthy() bool { - return len(ip.resultSet.Items()) > 0 -} - -func (tp resultSetItemsProxy) Len() int { - return len(tp.resultSet.Items()) -} - -func (tp resultSetItemsProxy) Index(k int) ucl.Object { - return itemProxy{resultSet: tp.resultSet, idx: k, item: tp.resultSet.Items()[k]} -} - -type itemProxy struct { - resultSet *models.ResultSet - idx int - item models.Item -} - -func (ip itemProxy) String() string { - return fmt.Sprintf("RSItems(%v)", len(ip.item)) -} - -func (ip itemProxy) Truthy() bool { - return len(ip.item) > 0 -} - -func (tp itemProxy) Len() int { - return len(tp.item) -} - -func (tp itemProxy) Value(k string) ucl.Object { - f, ok := tp.item[k] - if !ok { - return nil - } - return convertAttributeValueToUCLObject(f) -} - -func (tp itemProxy) Each(fn func(k string, v ucl.Object) error) error { - for key := range maps.Keys(tp.item) { - if err := fn(key, tp.Value(key)); err != nil { - return err - } - } - return nil -} - -func convertAttributeValueToUCLObject(attrValue types.AttributeValue) ucl.Object { - switch t := attrValue.(type) { - case *types.AttributeValueMemberS: - return ucl.StringObject(t.Value) - case *types.AttributeValueMemberN: - i, err := strconv.ParseInt(t.Value, 10, 64) - if err != nil { - return nil - } - return ucl.IntObject(i) - } - // TODO: the rest - return nil -} diff --git a/internal/common/ui/commandctrl/cmdpacks/pvars.go b/internal/common/ui/commandctrl/cmdpacks/pvars.go deleted file mode 100644 index f07965a..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/pvars.go +++ /dev/null @@ -1,62 +0,0 @@ -package cmdpacks - -import ( - "context" - "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/pkg/errors" -) - -type tablePVar struct { - state *controllers.State -} - -func (rs tablePVar) Get(ctx context.Context) (any, error) { - return newTableProxy(rs.state.ResultSet().TableInfo), nil -} - -type resultSetPVar struct { - state *controllers.State - readController *controllers.TableReadController -} - -func (rs resultSetPVar) Get(ctx context.Context) (any, error) { - return newResultSetProxy(rs.state.ResultSet()), nil -} - -func (rs resultSetPVar) Set(ctx context.Context, value any) error { - rsVal, ok := value.(SimpleProxy[*models.ResultSet]) - if !ok { - return errors.New("new value to @resultset is nil or not a result set") - } - - msg := rs.readController.SetResultSet(rsVal.value) - commandctrl.PostMsg(ctx, msg) - return nil -} - -type itemPVar struct { - state *controllers.State -} - -func (rs itemPVar) Get(ctx context.Context) (any, error) { - selItem, ok := commandctrl.SelectedItemIndex(ctx) - if !ok { - return nil, errors.New("no item selected") - } - return itemProxy{rs.state.ResultSet(), selItem, rs.state.ResultSet().Items()[selItem]}, nil -} - -func (rs itemPVar) Set(ctx context.Context, value any) error { - rsVal, ok := value.(itemProxy) - if !ok { - return errors.New("new value to @item is not an item") - } - - if msg := commandctrl.SetSelectedItemIndex(ctx, rsVal.idx); msg != nil { - commandctrl.PostMsg(ctx, msg) - } - - return nil -} diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go deleted file mode 100644 index 748aaea..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds.go +++ /dev/null @@ -1,440 +0,0 @@ -package cmdpacks - -import ( - "context" - tea "github.com/charmbracelet/bubbletea" - "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" - "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 - KeyBindingController *controllers.KeyBindingController - PBProvider services.PasteboardProvider - SettingsController *controllers.SettingsController - - modUI ucl.Module -} - -func NewStandardCommands( - tableService *tables.Service, - state *controllers.State, - readController *controllers.TableReadController, - writeController *controllers.TableWriteController, - exportController *controllers.ExportController, - keyBindingController *controllers.KeyBindingController, - pbProvider services.PasteboardProvider, - settingsController *controllers.SettingsController, -) StandardCommands { - modUI, ckbs := moduleUI(tableService, state, readController) - keyBindingController.SetCustomKeyBindingSource(ckbs) - - return StandardCommands{ - TableService: tableService, - State: state, - ReadController: readController, - WriteController: writeController, - ExportController: exportController, - KeyBindingController: keyBindingController, - PBProvider: pbProvider, - SettingsController: settingsController, - modUI: modUI, - } -} - -var cmdQuitDoc = repl.Doc{ - Brief: "Quits dynamo-browse", - Detailed: ` - This will quit dynamo-browse immediately, without prompting to apply - any changes. - `, -} - -func (sc StandardCommands) cmdQuit(ctx context.Context, args ucl.CallArgs) (any, error) { - commandctrl.PostMsg(ctx, tea.Quit) - return nil, nil -} - -var cmdTableDoc = repl.Doc{ - Brief: "Prompt for table to scan", - Usage: "[NAME]", - Args: []repl.ArgDoc{ - {Name: "name", Brief: "Name of the table to scan"}, - }, - Detailed: ` - If called with an argument, it will scan the table with that name and - replace the current result set. If called without an argument, it will - prompt for a table to scan. - - This command is intended only for interactive sessions and is not suitable - for scripting. The scan or table prompts will happen asynchronously. - `, -} - -func (sc StandardCommands) cmdTable(ctx context.Context, args ucl.CallArgs) (any, error) { - var tableName string - if err := args.Bind(&tableName); err == nil { - commandctrl.PostMsg(ctx, sc.ReadController.ScanTable(tableName)) - return nil, nil - } - - commandctrl.PostMsg(ctx, sc.ReadController.ListTables(false)) - return nil, nil -} - -var cmdExportDoc = repl.Doc{ - Brief: "Exports a result-set as CSV", - Usage: "FILENAME [-all]", - Args: []repl.ArgDoc{ - {Name: "filename", Brief: "Filename to export to"}, - {Name: "-all", Brief: "Export all results from the table"}, - }, - Detailed: ` - The fields of the current table view will be treated as the header of the - exported table. - - This command is intended only for interactive sessions and is not suitable - for scripting. The export will run asynchronously. - `, -} - -func (sc StandardCommands) cmdExport(ctx context.Context, args ucl.CallArgs) (any, error) { - var filename string - if err := args.Bind(&filename); err != nil { - return nil, errors.New("expected filename") - } - - opts := controllers.ExportOptions{ - AllResults: args.HasSwitch("all"), - } - - commandctrl.PostMsg(ctx, sc.ExportController.ExportCSV(filename, opts)) - return nil, nil -} - -var cmdMarkDoc = repl.Doc{ - Brief: "Set the marked items of the current result-set", - Usage: "[WHAT] [-where EXPR]", - Args: []repl.ArgDoc{ - {Name: "what", Brief: "Items to mark. Defaults to 'all'"}, - {Name: "-where", Brief: "Filter expression select items to mark"}, - }, - Detailed: ` - WHAT can be one of: - - - all: Mark all items in the current result-set - - none: Unmark all items in the current result-set - - toggle: Toggle the marked state of all items in the current result-set - `, -} - -func (sc StandardCommands) cmdMark(ctx context.Context, args ucl.CallArgs) (any, error) { - var markOp = controllers.MarkOpMark - - if args.NArgs() > 0 { - var markOpStr string - if err := args.Bind(&markOpStr); err == nil { - switch markOpStr { - case "all": - markOp = controllers.MarkOpMark - case "none": - markOp = controllers.MarkOpUnmark - case "toggle": - markOp = controllers.MarkOpToggle - default: - return nil, errors.New("unrecognised mark operation") - } - } - } - - var whereExpr = "" - if args.HasSwitch("where") { - if err := args.BindSwitch("where", &whereExpr); err != nil { - return nil, err - } - } - - commandctrl.PostMsg(ctx, sc.ReadController.Mark(markOp, whereExpr)) - return nil, nil -} - -var cmdNextPageDoc = repl.Doc{ - Brief: "Retrieve and display the next page of the current result-set", - Detailed: ` - This command is intended only for interactive sessions and is not suitable - for scripting. Fetching the next page will run asynchronously. - `, -} - -func (sc StandardCommands) cmdNextPage(ctx context.Context, args ucl.CallArgs) (any, error) { - commandctrl.PostMsg(ctx, sc.ReadController.NextPage()) - return nil, nil -} - -var cmdDeleteDoc = repl.Doc{ - Brief: "Delete the marked items of the current result-set", - Detailed: ` - The user will be prompted to confirm the deletion. If approved, the - items will be deleted immediately. - - This command is intended only for interactive sessions and is not suitable - for scripting. - `, -} - -func (sc StandardCommands) cmdDelete(ctx context.Context, args ucl.CallArgs) (any, error) { - commandctrl.PostMsg(ctx, sc.WriteController.DeleteMarked()) - return nil, nil -} - -var cmdNewItemDoc = repl.Doc{ - Brief: "Adds a new item to the current result-set", - Detailed: ` - The user will be prompted to enter the values for each required attribute, - such as the partition and sort key. The new item will be commited to the database - upon the next write. - - This command is intended only for interactive sessions and is not suitable - for scripting. - `, -} - -func (sc StandardCommands) cmdNewItem(ctx context.Context, args ucl.CallArgs) (any, error) { - commandctrl.PostMsg(ctx, sc.WriteController.NewItem()) - return nil, nil -} - -var cmdCloneDoc = repl.Doc{ - Brief: "Adds a copy of the selected item as a new item to the current result-set", - Detailed: ` - The user will be prompted to enter the partition and sort key. All other - attributes will be cloned from the selected item. The new item will be - commited to the database upon the next write. - - This command is intended only for interactive sessions and is not suitable - for scripting. - `, -} - -func (sc StandardCommands) cmdClone(ctx context.Context, args ucl.CallArgs) (any, error) { - selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) - if !ok { - return nil, errors.New("no item selected") - } - - commandctrl.PostMsg(ctx, sc.WriteController.CloneItem(selectedItemIndex)) - return nil, nil -} - -var cmdSetAttrDoc = repl.Doc{ - Brief: "Modify a field value of the selected or marked items", - Usage: "ATTR [TYPE]", - Args: []repl.ArgDoc{ - {Name: "attr", Brief: "Attribute to modify"}, - {Name: "-S", Brief: "Set attribute to a string"}, - {Name: "-N", Brief: "Set attribute to a number"}, - {Name: "-BOOL", Brief: "Set attribute to a boolean"}, - {Name: "-NULL", Brief: "Set attribute to a null"}, - {Name: "-TO", Brief: "Set attribute to the result of an expression"}, - }, - Detailed: ` - The user will be prompted to enter the new value for the attribute. - If the attribute type is not known, then a type will need to be specified. - Otherwise, the type will be unchanged. The modified item will be - commited to the database upon the next write. - `, -} - -func (sc StandardCommands) cmdSetAttr(ctx context.Context, args ucl.CallArgs) (any, error) { - var fieldName string - if err := args.Bind(&fieldName); err != nil { - return nil, errors.New("expected field name") - } - - selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) - if !ok { - return nil, errors.New("no item selected") - } - - var itemType = models.UnsetItemType - switch { - case args.HasSwitch("S"): - itemType = models.StringItemType - case args.HasSwitch("N"): - itemType = models.NumberItemType - case args.HasSwitch("BOOL"): - itemType = models.BoolItemType - case args.HasSwitch("NULL"): - itemType = models.NullItemType - case args.HasSwitch("TO"): - itemType = models.ExprValueItemType - } - - commandctrl.PostMsg(ctx, sc.WriteController.SetAttributeValue(selectedItemIndex, itemType, fieldName)) - return nil, nil -} - -var cmdDelAttrDoc = repl.Doc{ - Brief: "Remove the field of the selected or marked items", - Usage: "ATTR", - Args: []repl.ArgDoc{ - {Name: "attr", Brief: "Attribute to remove"}, - }, - Detailed: ` - The modified item will be commited to the database upon the next write. - `, -} - -func (sc StandardCommands) cmdDelAttr(ctx context.Context, args ucl.CallArgs) (any, error) { - var fieldName string - if err := args.Bind(&fieldName); err != nil { - return nil, errors.New("expected field name") - } - - selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) - if !ok { - return nil, errors.New("no item selected") - } - - commandctrl.PostMsg(ctx, sc.WriteController.DeleteAttribute(selectedItemIndex, fieldName)) - return nil, nil -} - -var cmdPutDoc = repl.Doc{ - Brief: "Commit changes to the table", - Detailed: ` - This will put all new and modified items. - - This command is intended only for interactive sessions and is not suitable - for scripting. The user will be prompted to confirm the changes. - `, -} - -func (sc StandardCommands) cmdPut(ctx context.Context, args ucl.CallArgs) (any, error) { - commandctrl.PostMsg(ctx, sc.WriteController.PutItems()) - return nil, nil -} - -var cmdTouchDoc = repl.Doc{ - Brief: "Put the currently selected item", - Detailed: ` - This will put the currently selected item, regardless of whether it has been - modified. - - This command is intended only for interactive sessions and is not suitable - for scripting. The user will be prompted to confirm the touch. - `, -} - -func (sc StandardCommands) cmdTouch(ctx context.Context, args ucl.CallArgs) (any, error) { - selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) - if !ok { - return nil, errors.New("no item selected") - } - - commandctrl.PostMsg(ctx, sc.WriteController.TouchItem(selectedItemIndex)) - return nil, nil -} - -var cmdNoisyTouchDoc = repl.Doc{ - Brief: "Put the currently selected item by deleting it first", - Detailed: ` - This will put the currently selected item, regardless of whether it has been - modified. It does so by removing the item from the table, then adding it back again. - - This command is intended only for interactive sessions and is not suitable - for scripting. The user will be prompted to confirm the touch. - `, -} - -func (sc StandardCommands) cmdNoisyTouch(ctx context.Context, args ucl.CallArgs) (any, error) { - selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx) - if !ok { - return nil, errors.New("no item selected") - } - - commandctrl.PostMsg(ctx, sc.WriteController.NoisyTouchItem(selectedItemIndex)) - return nil, nil -} - -var cmdRebindDoc = repl.Doc{ - Brief: "Binds a new key to an action", - Usage: "ACTION KEY", - Args: []repl.ArgDoc{ - {Name: "action", Brief: "Action to bind"}, - {Name: "key", Brief: "Key to bind"}, - }, - Detailed: ` - If the key is already bound to an action, it will be replaced. - The set of actions this command accepts is well-defined. For binding - to arbitrary actions, use the ui:bind command. - `, -} - -func (sc StandardCommands) cmdRebind(ctx context.Context, args ucl.CallArgs) (any, error) { - var bindingName, newKey string - if err := args.Bind(&bindingName, &newKey); err != nil { - return nil, errors.New("expected: bindingName newKey") - } - - // TODO: should only force if not interactive - commandctrl.PostMsg(ctx, sc.KeyBindingController.Rebind(bindingName, newKey, false)) - return nil, nil -} - -func (sc StandardCommands) InstOptions() []ucl.InstOption { - return []ucl.InstOption{ - ucl.WithModule(moduleRS(sc.TableService, sc.State)), - ucl.WithModule(sc.modUI), - ucl.WithModule(modulePB(sc.PBProvider)), - ucl.WithModule(moduleOpt(sc.SettingsController)), - } -} - -func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) { - ucl.SetBuiltin("quit", sc.cmdQuit) - ucl.SetBuiltin("table", sc.cmdTable) - ucl.SetBuiltin("export", sc.cmdExport) - ucl.SetBuiltin("mark", sc.cmdMark) - ucl.SetBuiltin("next-page", sc.cmdNextPage) - ucl.SetBuiltin("delete", sc.cmdDelete) - ucl.SetBuiltin("new-item", sc.cmdNewItem) - ucl.SetBuiltin("clone", sc.cmdClone) - ucl.SetBuiltin("set-attr", sc.cmdSetAttr) - ucl.SetBuiltin("del-attr", sc.cmdDelAttr) - ucl.SetBuiltin("put", sc.cmdPut) - ucl.SetBuiltin("touch", sc.cmdTouch) - ucl.SetBuiltin("noisy-touch", sc.cmdNoisyTouch) - ucl.SetBuiltin("rebind", sc.cmdRebind) - - // Aliases - ucl.SetBuiltin("sa", sc.cmdSetAttr) - ucl.SetBuiltin("da", sc.cmdDelAttr) - ucl.SetBuiltin("np", sc.cmdNextPage) - ucl.SetBuiltin("w", sc.cmdPut) - ucl.SetBuiltin("q", sc.cmdQuit) - - ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController}) - ucl.SetPseudoVar("table", tablePVar{sc.State}) - ucl.SetPseudoVar("item", itemPVar{sc.State}) -} - -func (sc StandardCommands) RunPrelude(ctx context.Context, ucl *ucl.Inst) error { - _, err := ucl.EvalString(ctx, uclPrelude) - return err -} - -const uclPrelude = ` -ui:command unmark { mark none } -ui:command set-opt { |n k| opt:set $n $k } -` diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go deleted file mode 100644 index 63f099f..0000000 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package cmdpacks_test - -import ( - "fmt" - tea "github.com/charmbracelet/bubbletea" - "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" - keybindings_service "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings" - "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/internal/dynamo-browse/ui/keybindings" - "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 testDataGenerator func() []testdynamo.TestData -type services struct { - CommandController *commandctrl.CommandController - SelItemIndex int - - State *controllers.State - - settingStore *settingstore.SettingStore - table string - - testDataGenerator testDataGenerator - testData []testdynamo.TestData -} - -type serviceOpt func(*services) - -func withDataGenerator(tg testDataGenerator) serviceOpt { - return func(s *services) { - s.testDataGenerator = tg - } -} - -func withTable(table string) serviceOpt { - return func(s *services) { - s.table = table - } -} - -func withDefaultLimit(limit int) serviceOpt { - return func(s *services) { - s.settingStore.SetDefaultLimit(limit) - } -} - -func newService(t *testing.T, opts ...serviceOpt) *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) - - s := &services{ - table: "service-test-data", - settingStore: settingStore, - testDataGenerator: normalTestData, - } - - for _, opt := range opts { - opt(s) - } - - s.testData = s.testDataGenerator() - client := testdynamo.SetupTestTable(t, s.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{}, - s.table, - ) - 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{}) - - keyBindingService := keybindings_service.NewService(keybindings.Default()) - keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil) - - commandController, _ := commandctrl.NewCommandController(inputHistoryService, - cmdpacks.NewStandardCommands( - service, - state, - readController, - writeController, - exportController, - keyBindingController, - pasteboardprovider.NilProvider{}, - settingsController, - ), - ) - - s.State = state - s.CommandController = commandController - - commandController.SetUIStateProvider(s) - readController.Init() - - return s -} - -func (s *services) SelectedItemIndex() int { - return s.SelItemIndex -} - -func (s *services) SetSelectedItemIndex(newIdx int) tea.Msg { - s.SelItemIndex = newIdx - return nil -} - -func normalTestData() []testdynamo.TestData { - return []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", - }, - }, - }, - } -} - -func largeTestData() []testdynamo.TestData { - return []testdynamo.TestData{ - { - TableName: "large-table", - Data: genRow(50, func(i int) map[string]interface{} { - return map[string]interface{}{ - "pk": fmt.Sprint(i), - "sk": fmt.Sprint(i), - "alpha": fmt.Sprintf("row %v", i), - } - }), - }, - } -} - -func genRow(count int, mapFn func(int) map[string]interface{}) []map[string]interface{} { - result := make([]map[string]interface{}, count) - for i := 0; i < count; i++ { - result[i] = mapFn(i) - } - return result -} diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 9075da8..c0d857f 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -1,17 +1,15 @@ package commandctrl import ( + "bufio" "bytes" "context" - "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" "log" "os" "path/filepath" "strings" - "ucl.lmika.dev/ucl" - "ucl.lmika.dev/ucl/builtins" "github.com/lmika/dynamo-browse/internal/common/ui/events" "github.com/lmika/shellwords" @@ -19,58 +17,19 @@ import ( const commandsCategory = "commands" -type cmdMessage struct { - cmd string -} - type CommandController struct { - uclInst *ucl.Inst historyProvider IterProvider commandList *CommandList lookupExtensions []CommandLookupExtension completionProvider CommandCompletionProvider - uiStateProvider UIStateProvider - cmdChan chan cmdMessage - msgChan chan tea.Msg - interactive bool } -func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*CommandController, error) { - cc := &CommandController{ +func NewCommandController(historyProvider IterProvider) *CommandController { + return &CommandController{ historyProvider: historyProvider, commandList: nil, lookupExtensions: nil, - cmdChan: make(chan cmdMessage), - msgChan: make(chan tea.Msg), - interactive: true, } - - 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) - } - - execCtx := execContext{ctrl: cc} - ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) - for _, pkg := range pkgs { - if err := pkg.RunPrelude(ctx, cc.uclInst); err != nil { - return nil, err - } - } - - go cc.cmdLooper() - - return cc, nil } func (c *CommandController) AddCommands(ctx *CommandList) { @@ -78,16 +37,6 @@ func (c *CommandController) AddCommands(ctx *CommandList) { c.commandList = ctx } -func (c *CommandController) StartMessageSender(msgSender func(tea.Msg)) { - for msg := range c.msgChan { - msgSender(msg) - } -} - -func (c *CommandController) SetUIStateProvider(provider UIStateProvider) { - c.uiStateProvider = provider -} - func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) { c.lookupExtensions = append(c.lookupExtensions, ext) } @@ -134,65 +83,29 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms return nil } - select { - case c.cmdChan <- cmdMessage{cmd: input}: - // good - default: - return events.Error(errors.New("command currently running")) + tokens := shellwords.Split(input) + command := c.lookupCommand(tokens[0]) + if command == nil { + return events.Error(errors.New("no such command: " + tokens[0])) } - return nil + return command(ctx, tokens[1:]) } -func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) { - return c.uclInst.EvalString(ctx, commandInput) -} - -func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea.Msg) { - execCtx := execContext{ctrl: c} - ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) - - res, err := invokable.Invoke(ctx, args) - if err != nil { - msg = events.Error(err) - } else if res != nil { - msg = events.StatusMsg(fmt.Sprint(res)) - } - if execCtx.requestRefresh { - c.postMessage(events.ResultSetUpdated{}) - } - - return msg -} - -func (c *CommandController) cmdLooper() { - execCtx := execContext{ctrl: c} - ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx) - - for { - select { - case cmdChan := <-c.cmdChan: - res, err := c.ExecuteAndWait(ctx, cmdChan.cmd) - if err != nil { - c.postMessage(events.Error(err)) - } else if res != nil { - c.postMessage(events.StatusMsg(fmt.Sprint(res))) - } - if execCtx.requestRefresh { - c.postMessage(events.ResultSetUpdated{}) - } - } - } -} - -func (c *CommandController) Alias(commandName string) Command { - return func(ctx ExecContext, args ucl.CallArgs) tea.Msg { +func (c *CommandController) Alias(commandName string, aliasArgs []string) Command { + return func(ctx ExecContext, args []string) tea.Msg { command := c.lookupCommand(commandName) if command == nil { return events.Error(errors.New("no such command: " + commandName)) } - return command(ctx, args) + var allArgs []string + if len(aliasArgs) > 0 { + allArgs = append(append([]string{}, aliasArgs...), args...) + } else { + allArgs = args + } + return command(ctx, allArgs) } } @@ -211,114 +124,39 @@ func (c *CommandController) lookupCommand(name string) Command { return nil } -func (c *CommandController) LoadExtensions(ctx context.Context, baseDirs []string) error { - log.Printf("loading extensions: %v", baseDirs) - for _, baseDir := range baseDirs { - baseDir = os.ExpandEnv(baseDir) - descendIntoSubDirs := !strings.HasSuffix(baseDir, ".") +func (c *CommandController) ExecuteFile(filename string) error { + baseFilename := filepath.Base(filename) - if stat, err := os.Stat(baseDir); err != nil { - if os.IsNotExist(err) { - continue - } - return err - } else if !stat.IsDir() { + if rcFile, err := os.ReadFile(filename); err == nil { + if err := c.executeFile(rcFile, baseFilename); err != nil { + return errors.Wrapf(err, "error executing %v", filename) + } + } else { + return errors.Wrapf(err, "error loading %v", filename) + } + return nil +} + +func (c *CommandController) executeFile(file []byte, filename string) error { + scnr := bufio.NewScanner(bytes.NewReader(file)) + + lineNo := 0 + for scnr.Scan() { + lineNo++ + line := strings.TrimSpace(scnr.Text()) + if line == "" { + continue + } else if line[0] == '#' { continue } - log.Printf("walking %v", baseDir) - if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - if !descendIntoSubDirs && path != baseDir { - return filepath.SkipDir - } - return nil - } - if strings.HasSuffix(info.Name(), ".ucl") { - if err := c.ExecuteFile(ctx, path); err != nil { - log.Println(err) - } - log.Printf("loaded %v\n", path) - } - return nil - }); err != nil { - return err + msg := c.execute(ExecContext{FromFile: true}, line) + switch m := msg.(type) { + case events.ErrorMsg: + log.Printf("%v:%v: error - %v", filename, lineNo, m.Error()) + case events.StatusMsg: + log.Printf("%v:%v: %v", filename, lineNo, string(m)) } } - return nil -} - -func (c *CommandController) ExecuteFile(ctx context.Context, filename string) error { - oldInteractive := c.interactive - c.interactive = false - defer func() { - c.interactive = oldInteractive - }() - - baseFilename := filepath.Base(filename) - - execCtx := execContext{ctrl: c} - ctx = context.WithValue(context.Background(), commandCtlKey, &execCtx) - - if rcFile, err := os.ReadFile(filename); err == nil { - if err := c.executeFile(ctx, rcFile); err != nil { - return errors.Wrapf(err, "error executing %v", baseFilename) - } - } else { - return errors.Wrapf(err, "error loading %v", baseFilename) - } - return nil -} - -func (c *CommandController) executeFile(ctx context.Context, file []byte) error { - if _, err := c.uclInst.Eval(ctx, bytes.NewReader(file), ucl.WithSubEnv()); err != nil { - return err - } - - return nil -} - -func (c *CommandController) Inst() *ucl.Inst { - return c.uclInst -} - -func (c *CommandController) cmdInvoker(ctx context.Context, name string, args ucl.CallArgs) (any, error) { - command := c.lookupCommand(name) - if command == nil { - return nil, errors.New("no such command: " + name) - } - - res := command(ExecContext{}, args) - if errMsg, isErrMsg := res.(events.ErrorMsg); isErrMsg { - return nil, errMsg - } - return teaMsgWrapper{res}, nil -} - -func (c *CommandController) printLine(s string) { - if c.msgChan == nil || !c.interactive { - log.Println(s) - return - } - - select { - case c.msgChan <- events.StatusMsg(s): - default: - log.Println(s) - } -} - -func (c *CommandController) postMessage(msg tea.Msg) { - if c.msgChan == nil { - return - } - - c.msgChan <- msg -} - -type teaMsgWrapper struct { - msg tea.Msg + return scnr.Err() } diff --git a/internal/common/ui/commandctrl/commandctrl_test.go b/internal/common/ui/commandctrl/commandctrl_test.go index bcc112a..b21783e 100644 --- a/internal/common/ui/commandctrl/commandctrl_test.go +++ b/internal/common/ui/commandctrl/commandctrl_test.go @@ -12,8 +12,7 @@ import ( func TestCommandController_Prompt(t *testing.T) { t.Run("prompt user for a command", func(t *testing.T) { - cmd, err := commandctrl.NewCommandController(mockIterProvider{}) - assert.NoError(t, err) + cmd := commandctrl.NewCommandController(mockIterProvider{}) res := cmd.Prompt() diff --git a/internal/common/ui/commandctrl/ctx.go b/internal/common/ui/commandctrl/ctx.go deleted file mode 100644 index dffff70..0000000 --- a/internal/common/ui/commandctrl/ctx.go +++ /dev/null @@ -1,63 +0,0 @@ -package commandctrl - -import ( - "context" - tea "github.com/charmbracelet/bubbletea" - "ucl.lmika.dev/ucl" -) - -type commandCtlKeyType struct{} - -var commandCtlKey = commandCtlKeyType{} - -type execContext struct { - ctrl *CommandController - requestRefresh bool -} - -func PostMsg(ctx context.Context, msg tea.Msg) { - cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) - if ok { - cmdCtl.ctrl.postMessage(msg) - } -} - -func SelectedItemIndex(ctx context.Context) (int, bool) { - cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) - if !ok { - return 0, false - } - - return cmdCtl.ctrl.uiStateProvider.SelectedItemIndex(), true -} - -func SetSelectedItemIndex(ctx context.Context, newIdx int) tea.Msg { - cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) - if !ok { - return nil - } - - return cmdCtl.ctrl.uiStateProvider.SetSelectedItemIndex(newIdx) -} - -func GetInvoker(ctx context.Context) Invoker { - cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) - if !ok { - return nil - } - - return cmdCtl.ctrl -} - -func QueueRefresh(ctx context.Context) { - cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext) - if !ok { - return - } - cmdCtl.requestRefresh = true -} - -type Invoker interface { - Invoke(invokable ucl.Invokable, args []any) tea.Msg - Inst() *ucl.Inst -} diff --git a/internal/common/ui/commandctrl/iface.go b/internal/common/ui/commandctrl/iface.go index e708d24..1cb834a 100644 --- a/internal/common/ui/commandctrl/iface.go +++ b/internal/common/ui/commandctrl/iface.go @@ -2,15 +2,9 @@ package commandctrl import ( "context" - tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services" ) type IterProvider interface { Iter(ctx context.Context, category string) services.HistoryProvider } - -type UIStateProvider interface { - SelectedItemIndex() int - SetSelectedItemIndex(newIdx int) tea.Msg -} diff --git a/internal/common/ui/commandctrl/packs.go b/internal/common/ui/commandctrl/packs.go deleted file mode 100644 index 16df613..0000000 --- a/internal/common/ui/commandctrl/packs.go +++ /dev/null @@ -1,12 +0,0 @@ -package commandctrl - -import ( - "context" - "ucl.lmika.dev/ucl" -) - -type CommandPack interface { - InstOptions() []ucl.InstOption - ConfigureUCL(ucl *ucl.Inst) - RunPrelude(ctx context.Context, ucl *ucl.Inst) error -} diff --git a/internal/common/ui/commandctrl/types.go b/internal/common/ui/commandctrl/types.go index cd922ff..7861e09 100644 --- a/internal/common/ui/commandctrl/types.go +++ b/internal/common/ui/commandctrl/types.go @@ -1,14 +1,11 @@ package commandctrl -import ( - tea "github.com/charmbracelet/bubbletea" - "ucl.lmika.dev/ucl" -) +import tea "github.com/charmbracelet/bubbletea" -type Command func(ctx ExecContext, args ucl.CallArgs) tea.Msg +type Command func(ctx ExecContext, args []string) tea.Msg func NoArgCommand(cmd tea.Cmd) Command { - return func(ctx ExecContext, args ucl.CallArgs) tea.Msg { + return func(ctx ExecContext, args []string) tea.Msg { return cmd() } } diff --git a/internal/common/ui/events/resultset.go b/internal/common/ui/events/resultset.go deleted file mode 100644 index cce739a..0000000 --- a/internal/common/ui/events/resultset.go +++ /dev/null @@ -1,5 +0,0 @@ -package events - -type ResultSetUpdated struct { - StatusMessage string -} diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index ac0e74a..7af1c41 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -81,6 +81,14 @@ type PromptForTableMsg struct { OnSelected func(tableName string) tea.Msg } +type ResultSetUpdated struct { + statusMessage string +} + +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 index 23c91a9..7f4ce8e 100644 --- a/internal/dynamo-browse/controllers/export.go +++ b/internal/dynamo-browse/controllers/export.go @@ -107,7 +107,7 @@ func (c *ExportController) ExportCSVToClipboard() tea.Msg { if err := c.pasteboardProvider.WriteText(bts.Bytes()); err != nil { return events.Error(err) } - return events.StatusMsg("Table copied to clipboard") + return nil } // TODO: this really needs to be a service! diff --git a/internal/dynamo-browse/controllers/keybinding.go b/internal/dynamo-browse/controllers/keybinding.go index 3b9dc76..043248f 100644 --- a/internal/dynamo-browse/controllers/keybinding.go +++ b/internal/dynamo-browse/controllers/keybinding.go @@ -20,10 +20,6 @@ func NewKeyBindingController(service *keybindings.Service, customBindingSource C } } -func (kb *KeyBindingController) SetCustomKeyBindingSource(customBindingSource CustomKeyBindingSource) { - kb.customBindingSource = customBindingSource -} - func (kb *KeyBindingController) Rebind(bindingName string, newKey string, force bool) tea.Msg { existingBinding := kb.findExistingBinding(newKey) if existingBinding == "" { diff --git a/internal/dynamo-browse/controllers/scripts.go b/internal/dynamo-browse/controllers/scripts.go new file mode 100644 index 0000000..f706ddd --- /dev/null +++ b/internal/dynamo-browse/controllers/scripts.go @@ -0,0 +1,281 @@ +package controllers + +import ( + "context" + "fmt" + "log" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" + "github.com/lmika/dynamo-browse/internal/common/ui/events" + "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/models/relitems" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + bus "github.com/lmika/events" + "github.com/pkg/errors" +) + +type ScriptController struct { + scriptManager *scriptmanager.Service + tableReadController *TableReadController + jobController *JobsController + settingsController *SettingsController + eventBus *bus.Bus + sendMsg func(msg tea.Msg) +} + +func NewScriptController( + scriptManager *scriptmanager.Service, + tableReadController *TableReadController, + jobController *JobsController, + settingsController *SettingsController, + eventBus *bus.Bus, +) *ScriptController { + sc := &ScriptController{ + scriptManager: scriptManager, + tableReadController: tableReadController, + jobController: jobController, + settingsController: settingsController, + eventBus: eventBus, + } + + sessionImpl := &sessionImpl{sc: sc, lastSelectedItemIndex: -1} + scriptManager.SetIFaces(scriptmanager.Ifaces{ + UI: &uiImpl{sc: sc}, + Session: sessionImpl, + }) + + sessionImpl.subscribeToEvents(eventBus) + + // Setup event handling when settings have changed + eventBus.On(BusEventSettingsUpdated, func(name, value string) { + if !strings.HasPrefix(name, "script.") { + return + } + sc.Init() + }) + + return sc +} + +func (sc *ScriptController) Init() { + if lookupPaths, err := sc.settingsController.settings.ScriptLookupFS(); err == nil { + sc.scriptManager.SetLookupPaths(lookupPaths) + } else { + log.Printf("warn: script lookup paths are invalid: %v", err) + } +} + +func (sc *ScriptController) SetMessageSender(sendMsg func(msg tea.Msg)) { + sc.sendMsg = sendMsg +} + +func (sc *ScriptController) LoadScript(filename string) tea.Msg { + ctx := context.Background() + plugin, err := sc.scriptManager.LoadScript(ctx, filename) + if err != nil { + return events.Error(err) + } + + return events.StatusMsg(fmt.Sprintf("Script '%v' loaded", plugin.Name())) +} + +func (sc *ScriptController) RunScript(filename string) tea.Msg { + ctx := context.Background() + if err := sc.scriptManager.StartAdHocScript(ctx, filename, sc.waitAndPrintScriptError()); err != nil { + return events.Error(err) + } + return nil +} + +func (sc *ScriptController) waitAndPrintScriptError() chan error { + errChan := make(chan error) + go func() { + if err := <-errChan; err != nil { + sc.sendMsg(events.Error(err)) + } + }() + return errChan +} + +func (sc *ScriptController) LookupCommand(name string) commandctrl.Command { + cmd := sc.scriptManager.LookupCommand(name) + if cmd == nil { + return nil + } + + return func(execCtx commandctrl.ExecContext, args []string) tea.Msg { + errChan := sc.waitAndPrintScriptError() + ctx := context.Background() + + if err := cmd.Invoke(ctx, args, errChan); err != nil { + return events.Error(err) + } + return nil + } +} + +type uiImpl struct { + sc *ScriptController +} + +func (u uiImpl) PrintMessage(ctx context.Context, msg string) { + u.sc.sendMsg(events.StatusMsg(msg)) +} + +func (u uiImpl) Prompt(ctx context.Context, msg string) chan string { + resultChan := make(chan string) + u.sc.sendMsg(events.PromptForInputMsg{ + Prompt: msg, + OnDone: func(value string) tea.Msg { + resultChan <- value + return nil + }, + OnCancel: func() tea.Msg { + close(resultChan) + return nil + }, + }) + return resultChan +} + +type sessionImpl struct { + sc *ScriptController + lastSelectedItemIndex int +} + +func (s *sessionImpl) subscribeToEvents(bus *bus.Bus) { + bus.On("ui.new-item-selected", func(rs *models.ResultSet, itemIndex int) { + s.lastSelectedItemIndex = itemIndex + }) +} + +func (s *sessionImpl) SelectedItemIndex(ctx context.Context) int { + return s.lastSelectedItemIndex +} + +func (s *sessionImpl) ResultSet(ctx context.Context) *models.ResultSet { + return s.sc.tableReadController.state.ResultSet() +} + +func (s *sessionImpl) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) { + state := s.sc.tableReadController.state + msg := s.sc.tableReadController.setResultSetAndFilter(newResultSet, state.filter, true, resultSetUpdateScript) + s.sc.sendMsg(msg) +} + +func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanager.QueryOptions) (*models.ResultSet, error) { + // Parse the query + expr, err := queryexpr.Parse(query) + if err != nil { + return nil, err + } + + if opts.NamePlaceholders != nil { + expr = expr.WithNameParams(opts.NamePlaceholders) + } + if opts.ValuePlaceholders != nil { + expr = expr.WithValueParams(opts.ValuePlaceholders) + } + if opts.IndexName != "" { + expr = expr.WithIndex(opts.IndexName) + } + + return s.sc.doQuery(ctx, expr, opts) +} + +func (s *ScriptController) doQuery(ctx context.Context, expr *queryexpr.QueryExpr, opts scriptmanager.QueryOptions) (*models.ResultSet, error) { + // Get the table info + var ( + tableInfo *models.TableInfo + err error + ) + + tableName := opts.TableName + currentResultSet := s.tableReadController.state.ResultSet() + + if tableName != "" { + // Table specified. If it's the same as the current table, then use the existing table info + if currentResultSet != nil && currentResultSet.TableInfo.Name == tableName { + tableInfo = currentResultSet.TableInfo + } + + // Otherwise, describe the table + tableInfo, err = s.tableReadController.tableService.Describe(ctx, tableName) + if err != nil { + return nil, errors.Wrapf(err, "cannot describe table '%v'", tableName) + } + } else { + // Table not specified. Use the existing table, if any + if currentResultSet == nil { + return nil, errors.New("no table currently selected") + } + tableInfo = currentResultSet.TableInfo + } + + newResultSet, err := s.tableReadController.tableService.ScanOrQuery(ctx, tableInfo, expr, nil) + if err != nil { + return nil, err + } + return newResultSet, nil +} + +func (sc *ScriptController) CustomKeyCommand(key string) tea.Cmd { + _, cmd := sc.scriptManager.LookupKeyBinding(key) + if cmd == nil { + return nil + } + + return func() tea.Msg { + errChan := sc.waitAndPrintScriptError() + ctx := context.Background() + + if err := cmd.Invoke(ctx, nil, errChan); err != nil { + return events.Error(err) + } + return nil + } +} + +func (sc *ScriptController) Rebind(bindingName string, newKey string) error { + return sc.scriptManager.RebindKeyBinding(bindingName, newKey) +} + +func (sc *ScriptController) LookupBinding(theKey string) string { + bindingName, _ := sc.scriptManager.LookupKeyBinding(theKey) + return bindingName +} + +func (sc *ScriptController) UnbindKey(key string) { + sc.scriptManager.UnbindKey(key) +} + +func (c *ScriptController) LookupRelatedItems(idx int) (res tea.Msg) { + rs := c.tableReadController.state.ResultSet() + + relItems, err := c.scriptManager.RelatedItemOfItem(context.Background(), rs, idx) + if err != nil { + return events.Error(err) + } else if len(relItems) == 0 { + return events.StatusMsg("No related items available") + } + + return ShowRelatedItemsOverlay{ + Items: relItems, + OnSelected: func(item relitems.RelatedItem) tea.Msg { + if item.OnSelect != nil { + return item.OnSelect() + } + + return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) { + return c.doQuery(ctx, item.Query, scriptmanager.QueryOptions{ + TableName: item.Table, + }) + }).OnDone(func(rs *models.ResultSet) tea.Msg { + return c.tableReadController.setResultSetAndFilter(rs, "", true, resultSetUpdateQuery) + }).Submit() + }, + } +} diff --git a/internal/dynamo-browse/controllers/scripts_test.go b/internal/dynamo-browse/controllers/scripts_test.go new file mode 100644 index 0000000..ffcb8a8 --- /dev/null +++ b/internal/dynamo-browse/controllers/scripts_test.go @@ -0,0 +1,192 @@ +package controllers_test + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/common/ui/events" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers" + "github.com/stretchr/testify/assert" +) + +func TestScriptController_RunScript(t *testing.T) { + t.Run("should execute scripts successfully", func(t *testing.T) { + srv := newService(t, serviceConfig{ + scriptFS: testScriptFile(t, "test.tm", ` + ui.print("Hello world") + `), + }) + + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("Hello world"), srv.msgSender.msgs[0]) + }) + + t.Run("session.result_set", func(t *testing.T) { + t.Run("should return current result set if not-nil", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.result_set() + ui.print(rs.length) + `), + }) + + invokeCommand(t, srv.readController.Init()) + + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("3"), srv.msgSender.msgs[0]) + }) + }) + + t.Run("session.query", func(t *testing.T) { + t.Run("should run query against current table", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk="abc"') + ui.print(rs.length) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("2"), srv.msgSender.msgs[0]) + }) + + t.Run("should run query against another table", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk!="abc"', { table: "count-to-30" }) + ui.print(rs.length) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("30"), srv.msgSender.msgs[0]) + }) + }) + + t.Run("session.set_result_set", func(t *testing.T) { + t.Run("should set the result set from the result of a query", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk="abc"') + session.set_result_set(rs) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.IsType(t, controllers.NewResultSet{}, srv.msgSender.msgs[0]) + }) + + t.Run("changed attributes of the result set should show up as modified", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + rs := session.query('pk="abc"') + rs[0].set_attr("pk", "131") + session.set_result_set(rs) + `), + }) + + invokeCommand(t, srv.readController.Init()) + msg := srv.scriptController.RunScript("test.tm") + assert.Nil(t, msg) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.IsType(t, controllers.NewResultSet{}, srv.msgSender.msgs[0]) + + assert.Equal(t, "131", srv.state.ResultSet().Items()[0]["pk"].(*types.AttributeValueMemberS).Value) + assert.True(t, srv.state.ResultSet().IsDirty(0)) + }) + }) +} + +func TestScriptController_LookupCommand(t *testing.T) { + t.Run("should schedule the script on a separate go-routine", func(t *testing.T) { + scenarios := []struct { + descr string + command string + expectedOutput string + }{ + {descr: "command with arg", command: "mycommand \"test name\"", expectedOutput: "Hello, test name"}, + {descr: "command no arg", command: "mycommand", expectedOutput: "Hello, nil value"}, + } + + for _, scenario := range scenarios { + t.Run(scenario.descr, func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + ext.command("mycommand", func(name = "nil value") { + ui.print(sprintf("Hello, %v", name)) + }) + `), + }) + + invokeCommand(t, srv.scriptController.LoadScript("test.tm")) + invokeCommand(t, srv.commandController.Execute(scenario.command)) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg(scenario.expectedOutput), srv.msgSender.msgs[0]) + }) + } + }) + + t.Run("should only allow one script to run at a time", func(t *testing.T) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + ext.command("mycommand", func() { + time.sleep(1.5) + ui.print("Done my thing") + }) + `), + }) + + invokeCommand(t, srv.scriptController.LoadScript("test.tm")) + + invokeCommand(t, srv.commandController.Execute(`mycommand`)) + invokeCommandExpectingError(t, srv.commandController.Execute(`mycommand`)) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("Done my thing"), srv.msgSender.msgs[0]) + }) + +} diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index 8f7ea82..6a886d2 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -29,14 +29,6 @@ func (s *State) Filter() string { return s.filter } -func (s *State) SetResultSet(resultSet *models.ResultSet) { - s.mutex.Lock() - defer s.mutex.Unlock() - - s.resultSet = resultSet - s.filter = "" -} - func (s *State) withResultSet(rs func(*models.ResultSet)) { s.mutex.Lock() defer s.mutex.Unlock() diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 64aca40..6ef6e18 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -61,6 +61,7 @@ type TableReadController struct { tableName string loadFromLastView bool pasteboardProvider services.PasteboardProvider + relatedItemSupplier RelatedItemSupplier // state mutex *sync.Mutex @@ -76,6 +77,7 @@ func NewTableReadController( inputHistoryService *inputhistory.Service, eventBus *bus.Bus, pasteboardProvider services.PasteboardProvider, + relatedItemSupplier RelatedItemSupplier, tableName string, ) *TableReadController { return &TableReadController{ @@ -88,6 +90,7 @@ func NewTableReadController( eventBus: eventBus, tableName: tableName, pasteboardProvider: pasteboardProvider, + relatedItemSupplier: relatedItemSupplier, mutex: new(sync.Mutex), } } @@ -179,10 +182,6 @@ func (c *TableReadController) PromptForQuery() tea.Msg { } } -func (c *TableReadController) RunQuery(q *queryexpr.QueryExpr, table *models.TableInfo) tea.Msg { - return c.runQuery(table, q, "", true, nil) -} - func (c *TableReadController) runQuery( tableInfo *models.TableInfo, query *queryexpr.QueryExpr, @@ -292,12 +291,6 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, return c.state.buildNewResultSetMessage("") } -func (c *TableReadController) SetResultSet(resultSet *models.ResultSet) tea.Msg { - c.state.setResultSetAndFilter(resultSet, "") - c.eventBus.Fire(newResultSetEvent, resultSet, resultSetUpdateScript) - return c.state.buildNewResultSetMessage("") -} - func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg { var ( whereExpr *queryexpr.QueryExpr @@ -340,31 +333,27 @@ func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg { }); err != nil { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} } -func (c *TableReadController) PromptForFilter() tea.Msg { +func (c *TableReadController) Filter() tea.Msg { return events.PromptForInputMsg{ Prompt: "filter: ", History: c.inputHistoryService.Iter(context.Background(), filterInputHistoryCategory), OnDone: func(value string) tea.Msg { - return c.Filter(value) + resultSet := c.state.ResultSet() + if resultSet == nil { + return events.StatusMsg("Result-set is nil") + } + + return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) { + newResultSet := c.tableService.Filter(resultSet, value) + return newResultSet, nil + }).OnEither(c.handleResultSetFromJobResult(value, true, false, resultSetUpdateFilter)).Submit() }, } } -func (c *TableReadController) Filter(value string) tea.Msg { - resultSet := c.state.ResultSet() - if resultSet == nil { - return events.StatusMsg("Result-set is nil") - } - - return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) { - newResultSet := c.tableService.Filter(resultSet, value) - return newResultSet, nil - }).OnEither(c.handleResultSetFromJobResult(value, true, false, resultSetUpdateFilter)).Submit() -} - func (c *TableReadController) handleResultSetFromJobResult( filter string, pushbackStack, errIfEmpty bool, diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 8f248c5..033a0cb 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -44,7 +44,7 @@ func (twc *TableWriteController) ToggleMark(idx int) tea.Msg { resultSet.SetMark(idx, !resultSet.Marked(idx)) }) - return events.ResultSetUpdated{} + return ResultSetUpdated{} } func (twc *TableWriteController) NewItem() tea.Msg { @@ -148,7 +148,7 @@ func (twc *TableWriteController) setStringValue(idx int, attr *queryexpr.QueryEx }); err != nil { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} }, } } @@ -181,7 +181,7 @@ func (twc *TableWriteController) setToExpressionValue(idx int, attr *queryexpr.Q }); err != nil { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} }, } } @@ -205,7 +205,7 @@ func (twc *TableWriteController) setNumberValue(idx int, attr *queryexpr.QueryEx }); err != nil { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} }, } } @@ -234,7 +234,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr *queryexpr.QueryExpr }); err != nil { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} }, } } @@ -255,7 +255,7 @@ func (twc *TableWriteController) setNullValue(idx int, attr *queryexpr.QueryExpr }); err != nil { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} } func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg { @@ -291,7 +291,7 @@ func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} } func (twc *TableWriteController) PutItems() tea.Msg { @@ -351,8 +351,8 @@ func (twc *TableWriteController) PutItems() tea.Msg { } return rs, nil }).OnDone(func(rs *models.ResultSet) tea.Msg { - return events.ResultSetUpdated{ - StatusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), + return ResultSetUpdated{ + statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), } }).Submit() }, @@ -379,7 +379,7 @@ func (twc *TableWriteController) TouchItem(idx int) tea.Msg { if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil { return events.Error(err) } - return events.ResultSetUpdated{} + return ResultSetUpdated{} }, } } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 0ace3d9..efd0c14 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -15,6 +15,7 @@ import ( "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/scriptmanager" "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" @@ -586,6 +587,7 @@ type services struct { settingsController *controllers.SettingsController columnsController *controllers.ColumnsController exportController *controllers.ExportController + scriptController *controllers.ScriptController commandController *commandctrl.CommandController } @@ -605,6 +607,7 @@ func newService(t *testing.T, cfg serviceConfig) *services { workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer()) + scriptService := scriptmanager.New() inputHistoryService := inputhistory.New(inputHistoryStore) client := testdynamo.SetupTestTable(t, testData) @@ -624,14 +627,17 @@ func newService(t *testing.T, cfg serviceConfig) *services { inputHistoryService, eventBus, pasteboardprovider.NilProvider{}, + nil, cfg.tableName, ) 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{}) + scriptController := controllers.NewScriptController(scriptService, readController, jobsController, settingsController, eventBus) - commandController, _ := commandctrl.NewCommandController(inputHistoryService) + commandController := commandctrl.NewCommandController(inputHistoryService) + commandController.AddCommandLookupExtension(scriptController) if cfg.isReadOnly { if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil { @@ -645,7 +651,12 @@ func newService(t *testing.T, cfg serviceConfig) *services { } msgSender := &msgSender{} + scriptController.Init() jobsController.SetMessageSender(msgSender.send) + scriptController.SetMessageSender(msgSender.send) + + // Initting will setup the default script lookup paths, so revert them to the test ones + scriptService.SetLookupPaths([]fs.FS{cfg.scriptFS}) return &services{ state: state, @@ -655,6 +666,7 @@ func newService(t *testing.T, cfg serviceConfig) *services { settingsController: settingsController, columnsController: columnsController, exportController: exportController, + scriptController: scriptController, commandController: commandController, msgSender: msgSender, } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index ff4e398..03d0421 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -151,37 +151,3 @@ func (rs *ResultSet) Sort(criteria SortCriteria) { rs.sortCriteria = criteria Sort(rs.items, criteria) } - -func (rs *ResultSet) MergeWith(otherRS *ResultSet) *ResultSet { - type pksk struct { - pk types.AttributeValue - sk types.AttributeValue - } - - if !rs.TableInfo.Equal(otherRS.TableInfo) { - return nil - } - - itemsInI := make(map[pksk]Item) - newItems := make([]Item, 0, len(rs.Items())+len(otherRS.Items())) - for _, item := range rs.Items() { - pk, sk := item.PKSK(rs.TableInfo) - itemsInI[pksk{pk, sk}] = item - newItems = append(newItems, item) - } - - for _, item := range otherRS.Items() { - pk, sk := item.PKSK(rs.TableInfo) - if _, hasItem := itemsInI[pksk{pk, sk}]; !hasItem { - newItems = append(newItems, item) - } - } - - newResultSet := &ResultSet{ - Created: time.Now(), - TableInfo: rs.TableInfo, - } - newResultSet.SetItems(newItems) - - return newResultSet -} diff --git a/internal/dynamo-browse/models/queryexpr/types.go b/internal/dynamo-browse/models/queryexpr/types.go index 011931e..2e55c7a 100644 --- a/internal/dynamo-browse/models/queryexpr/types.go +++ b/internal/dynamo-browse/models/queryexpr/types.go @@ -8,7 +8,6 @@ import ( "github.com/pkg/errors" "math/big" "strconv" - "strings" ) type exprValue interface { @@ -63,14 +62,6 @@ 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 @@ -148,32 +139,6 @@ 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 } diff --git a/internal/dynamo-browse/services/scriptmanager/builtins.go b/internal/dynamo-browse/services/scriptmanager/builtins.go new file mode 100644 index 0000000..93c6e78 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/builtins.go @@ -0,0 +1,102 @@ +/** + * Builtins adopted and modified from Taramin + * Copyright (c) 2022 Curtis Myzie + */ + +package scriptmanager + +import ( + "context" + "fmt" + "log" + + "github.com/pkg/errors" + "github.com/risor-io/risor/object" +) + +func printBuiltin(ctx context.Context, args ...object.Object) object.Object { + env := scriptEnvFromCtx(ctx) + prefix := "script " + env.filename + ":" + + values := make([]interface{}, len(args)+1) + values[0] = prefix + for i, arg := range args { + switch arg := arg.(type) { + case *object.String: + values[i+1] = arg.Value() + default: + values[i+1] = arg.Inspect() + } + } + log.Println(values...) + return object.Nil +} + +func printfBuiltin(ctx context.Context, args ...object.Object) object.Object { + env := scriptEnvFromCtx(ctx) + prefix := "script " + env.filename + ":" + + numArgs := len(args) + if numArgs < 1 { + return object.Errorf("type error: printf() takes 1 or more arguments (%d given)", len(args)) + } + format, err := object.AsString(args[0]) + if err != nil { + return err + } + var values = []interface{}{prefix} + for _, arg := range args[1:] { + switch arg := arg.(type) { + case *object.String: + values = append(values, arg.Value()) + default: + values = append(values, arg.Interface()) + } + } + log.Printf("%s "+format, values...) + return object.Nil +} + +// This is taken from the args package +func require(funcName string, count int, args []object.Object) *object.Error { + nArgs := len(args) + if nArgs != count { + if count == 1 { + return object.Errorf( + fmt.Sprintf("type error: %s() takes exactly 1 argument (%d given)", + funcName, nArgs)) + } + return object.Errorf( + fmt.Sprintf("type error: %s() takes exactly %d arguments (%d given)", + funcName, count, nArgs)) + } + return nil +} + +func bindArgs(funcName string, args []object.Object, bindArgs ...any) *object.Error { + if err := require(funcName, len(bindArgs), args); err != nil { + return err + } + + for i, bindArg := range bindArgs { + switch t := bindArg.(type) { + case *string: + str, err := object.AsString(args[i]) + if err != nil { + return err + } + + *t = str + case **object.Function: + fnRes, isFnRes := args[i].(*object.Function) + if !isFnRes { + return object.NewError(errors.Errorf("expected arg %v to be a function, was %T", i, bindArg)) + } + + *t = fnRes + default: + return object.NewError(errors.Errorf("unhandled arg type %v", i)) + } + } + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/iface.go b/internal/dynamo-browse/services/scriptmanager/iface.go new file mode 100644 index 0000000..39b4d9e --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/iface.go @@ -0,0 +1,38 @@ +package scriptmanager + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" +) + +//go:generate mockery --with-expecter --name UIService +//go:generate mockery --with-expecter --name SessionService + +type Ifaces struct { + UI UIService + Session SessionService +} + +type UIService interface { + PrintMessage(ctx context.Context, msg string) + + // Prompt should return a channel which will provide the input from the user. If the user + // provides no input, prompt should close the channel without providing anything. + Prompt(ctx context.Context, msg string) chan string +} + +type SessionService interface { + Query(ctx context.Context, expr string, queryOptions QueryOptions) (*models.ResultSet, error) + + ResultSet(ctx context.Context) *models.ResultSet + SelectedItemIndex(ctx context.Context) int + SetResultSet(ctx context.Context, newResultSet *models.ResultSet) +} + +type QueryOptions struct { + TableName string + IndexName string + NamePlaceholders map[string]string + ValuePlaceholders map[string]types.AttributeValue +} diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go new file mode 100644 index 0000000..bdfa6b1 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go @@ -0,0 +1,216 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + mock "github.com/stretchr/testify/mock" + + scriptmanager "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" +) + +// SessionService is an autogenerated mock type for the SessionService type +type SessionService struct { + mock.Mock +} + +type SessionService_Expecter struct { + mock *mock.Mock +} + +func (_m *SessionService) EXPECT() *SessionService_Expecter { + return &SessionService_Expecter{mock: &_m.Mock} +} + +// Query provides a mock function with given fields: ctx, expr, queryOptions +func (_m *SessionService) Query(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions) (*models.ResultSet, error) { + ret := _m.Called(ctx, expr, queryOptions) + + var r0 *models.ResultSet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)); ok { + return rf(ctx, expr, queryOptions) + } + if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) *models.ResultSet); ok { + r0 = rf(ctx, expr, queryOptions) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.ResultSet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, scriptmanager.QueryOptions) error); ok { + r1 = rf(ctx, expr, queryOptions) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SessionService_Query_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Query' +type SessionService_Query_Call struct { + *mock.Call +} + +// Query is a helper method to define mock.On call +// - ctx context.Context +// - expr string +// - queryOptions scriptmanager.QueryOptions +func (_e *SessionService_Expecter) Query(ctx interface{}, expr interface{}, queryOptions interface{}) *SessionService_Query_Call { + return &SessionService_Query_Call{Call: _e.mock.On("Query", ctx, expr, queryOptions)} +} + +func (_c *SessionService_Query_Call) Run(run func(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions)) *SessionService_Query_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(scriptmanager.QueryOptions)) + }) + return _c +} + +func (_c *SessionService_Query_Call) Return(_a0 *models.ResultSet, _a1 error) *SessionService_Query_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *SessionService_Query_Call) RunAndReturn(run func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)) *SessionService_Query_Call { + _c.Call.Return(run) + return _c +} + +// ResultSet provides a mock function with given fields: ctx +func (_m *SessionService) ResultSet(ctx context.Context) *models.ResultSet { + ret := _m.Called(ctx) + + var r0 *models.ResultSet + if rf, ok := ret.Get(0).(func(context.Context) *models.ResultSet); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.ResultSet) + } + } + + return r0 +} + +// SessionService_ResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResultSet' +type SessionService_ResultSet_Call struct { + *mock.Call +} + +// ResultSet is a helper method to define mock.On call +// - ctx context.Context +func (_e *SessionService_Expecter) ResultSet(ctx interface{}) *SessionService_ResultSet_Call { + return &SessionService_ResultSet_Call{Call: _e.mock.On("ResultSet", ctx)} +} + +func (_c *SessionService_ResultSet_Call) Run(run func(ctx context.Context)) *SessionService_ResultSet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *SessionService_ResultSet_Call) Return(_a0 *models.ResultSet) *SessionService_ResultSet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SessionService_ResultSet_Call) RunAndReturn(run func(context.Context) *models.ResultSet) *SessionService_ResultSet_Call { + _c.Call.Return(run) + return _c +} + +// SelectedItemIndex provides a mock function with given fields: ctx +func (_m *SessionService) SelectedItemIndex(ctx context.Context) int { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// SessionService_SelectedItemIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectedItemIndex' +type SessionService_SelectedItemIndex_Call struct { + *mock.Call +} + +// SelectedItemIndex is a helper method to define mock.On call +// - ctx context.Context +func (_e *SessionService_Expecter) SelectedItemIndex(ctx interface{}) *SessionService_SelectedItemIndex_Call { + return &SessionService_SelectedItemIndex_Call{Call: _e.mock.On("SelectedItemIndex", ctx)} +} + +func (_c *SessionService_SelectedItemIndex_Call) Run(run func(ctx context.Context)) *SessionService_SelectedItemIndex_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *SessionService_SelectedItemIndex_Call) Return(_a0 int) *SessionService_SelectedItemIndex_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *SessionService_SelectedItemIndex_Call) RunAndReturn(run func(context.Context) int) *SessionService_SelectedItemIndex_Call { + _c.Call.Return(run) + return _c +} + +// SetResultSet provides a mock function with given fields: ctx, newResultSet +func (_m *SessionService) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) { + _m.Called(ctx, newResultSet) +} + +// SessionService_SetResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetResultSet' +type SessionService_SetResultSet_Call struct { + *mock.Call +} + +// SetResultSet is a helper method to define mock.On call +// - ctx context.Context +// - newResultSet *models.ResultSet +func (_e *SessionService_Expecter) SetResultSet(ctx interface{}, newResultSet interface{}) *SessionService_SetResultSet_Call { + return &SessionService_SetResultSet_Call{Call: _e.mock.On("SetResultSet", ctx, newResultSet)} +} + +func (_c *SessionService_SetResultSet_Call) Run(run func(ctx context.Context, newResultSet *models.ResultSet)) *SessionService_SetResultSet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*models.ResultSet)) + }) + return _c +} + +func (_c *SessionService_SetResultSet_Call) Return() *SessionService_SetResultSet_Call { + _c.Call.Return() + return _c +} + +func (_c *SessionService_SetResultSet_Call) RunAndReturn(run func(context.Context, *models.ResultSet)) *SessionService_SetResultSet_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTNewSessionService interface { + mock.TestingT + Cleanup(func()) +} + +// NewSessionService creates a new instance of SessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSessionService(t mockConstructorTestingTNewSessionService) *SessionService { + mock := &SessionService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go new file mode 100644 index 0000000..b029dd6 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go @@ -0,0 +1,116 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// UIService is an autogenerated mock type for the UIService type +type UIService struct { + mock.Mock +} + +type UIService_Expecter struct { + mock *mock.Mock +} + +func (_m *UIService) EXPECT() *UIService_Expecter { + return &UIService_Expecter{mock: &_m.Mock} +} + +// PrintMessage provides a mock function with given fields: ctx, msg +func (_m *UIService) PrintMessage(ctx context.Context, msg string) { + _m.Called(ctx, msg) +} + +// UIService_PrintMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrintMessage' +type UIService_PrintMessage_Call struct { + *mock.Call +} + +// PrintMessage is a helper method to define mock.On call +// - ctx context.Context +// - msg string +func (_e *UIService_Expecter) PrintMessage(ctx interface{}, msg interface{}) *UIService_PrintMessage_Call { + return &UIService_PrintMessage_Call{Call: _e.mock.On("PrintMessage", ctx, msg)} +} + +func (_c *UIService_PrintMessage_Call) Run(run func(ctx context.Context, msg string)) *UIService_PrintMessage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UIService_PrintMessage_Call) Return() *UIService_PrintMessage_Call { + _c.Call.Return() + return _c +} + +func (_c *UIService_PrintMessage_Call) RunAndReturn(run func(context.Context, string)) *UIService_PrintMessage_Call { + _c.Call.Return(run) + return _c +} + +// Prompt provides a mock function with given fields: ctx, msg +func (_m *UIService) Prompt(ctx context.Context, msg string) chan string { + ret := _m.Called(ctx, msg) + + var r0 chan string + if rf, ok := ret.Get(0).(func(context.Context, string) chan string); ok { + r0 = rf(ctx, msg) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan string) + } + } + + return r0 +} + +// UIService_Prompt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Prompt' +type UIService_Prompt_Call struct { + *mock.Call +} + +// Prompt is a helper method to define mock.On call +// - ctx context.Context +// - msg string +func (_e *UIService_Expecter) Prompt(ctx interface{}, msg interface{}) *UIService_Prompt_Call { + return &UIService_Prompt_Call{Call: _e.mock.On("Prompt", ctx, msg)} +} + +func (_c *UIService_Prompt_Call) Run(run func(ctx context.Context, msg string)) *UIService_Prompt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UIService_Prompt_Call) Return(_a0 chan string) *UIService_Prompt_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *UIService_Prompt_Call) RunAndReturn(run func(context.Context, string) chan string) *UIService_Prompt_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTNewUIService interface { + mock.TestingT + Cleanup(func()) +} + +// NewUIService creates a new instance of UIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewUIService(t mockConstructorTestingTNewUIService) *UIService { + mock := &UIService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/dynamo-browse/services/scriptmanager/modext.go b/internal/dynamo-browse/services/scriptmanager/modext.go new file mode 100644 index 0000000..4ad97d4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modext.go @@ -0,0 +1,270 @@ +package scriptmanager + +import ( + "context" + "fmt" + "regexp" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" +) + +var ( + validKeyBindingNames = regexp.MustCompile(`^[-a-zA-Z0-9_]+$`) +) + +type extModule struct { + scriptPlugin *ScriptPlugin +} + +func (m *extModule) register() *object.Module { + return object.NewBuiltinsModule("ext", map[string]object.Object{ + "command": object.NewBuiltin("command", m.command), + "key_binding": object.NewBuiltin("key_binding", m.keyBinding), + "related_items": object.NewBuiltin("related_items", m.relatedItem), + }) +} + +func (m *extModule) command(ctx context.Context, args ...object.Object) object.Object { + thisEnv := scriptEnvFromCtx(ctx) + + if err := require("ext.command", 2, args); err != nil { + return err + } + + cmdName, err := object.AsString(args[0]) + if err != nil { + return err + } + fnRes, isFnRes := args[1].(*object.Function) + if !isFnRes { + return object.NewError(errors.New("expected second arg to be a function")) + } + + callFn, hasCallFn := object.GetCallFunc(ctx) + if !hasCallFn { + return object.NewError(errors.New("no callFn found in context")) + } + + // This command function will be executed by the script scheduler + newCommand := func(ctx context.Context, args []string) error { + objArgs := make([]object.Object, len(args)) + for i, a := range args { + objArgs[i] = object.NewString(a) + } + + newEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, newEnv) + + res, err := callFn(ctx, fnRes, objArgs) + if err != nil { + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, cmdName, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, cmdName, errObj.Inspect()) + } + return nil + } + + if m.scriptPlugin.definedCommands == nil { + m.scriptPlugin.definedCommands = make(map[string]*Command) + } + m.scriptPlugin.definedCommands[cmdName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand} + return nil +} + +func (m *extModule) keyBinding(ctx context.Context, args ...object.Object) object.Object { + thisEnv := scriptEnvFromCtx(ctx) + + if err := require("ext.key_binding", 3, args); err != nil { + return err + } + + bindingName, err := object.AsString(args[0]) + if err != nil { + return err + } else if !validKeyBindingNames.MatchString(bindingName) { + return object.NewError(errors.New("value error: binding name must match regexp [-a-zA-Z0-9_]+")) + } + + options, err := object.AsMap(args[1]) + if err != nil { + return err + } + + var defaultKey string + if strVal, isStrVal := options.Get("default").(*object.String); isStrVal { + defaultKey = strVal.Value() + } + + fnRes, isFnRes := args[2].(*object.Function) + if !isFnRes { + return object.NewError(errors.New("expected second arg to be a function")) + } + + callFn, hasCallFn := object.GetCallFunc(ctx) + if !hasCallFn { + return object.NewError(errors.New("no callFn found in context")) + } + + // This command function will be executed by the script scheduler + newCommand := func(ctx context.Context, args []string) error { + objArgs := make([]object.Object, len(args)) + for i, a := range args { + objArgs[i] = object.NewString(a) + } + + newEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, newEnv) + + res, err := callFn(ctx, fnRes, objArgs) + if err != nil { + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, bindingName, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, bindingName, errObj.Inspect()) + } + return nil + } + + fullBindingName := fmt.Sprintf("ext.%v.%v", m.scriptPlugin.name, bindingName) + + if m.scriptPlugin.definedKeyBindings == nil { + m.scriptPlugin.definedKeyBindings = make(map[string]*Command) + m.scriptPlugin.keyToKeyBinding = make(map[string]string) + } + + m.scriptPlugin.definedKeyBindings[fullBindingName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand} + m.scriptPlugin.keyToKeyBinding[defaultKey] = fullBindingName + return nil +} + +func (m *extModule) relatedItem(ctx context.Context, args ...object.Object) object.Object { + thisEnv := scriptEnvFromCtx(ctx) + + var ( + tableName string + callbackFn *object.Function + ) + if err := bindArgs("ext.related_items", args, &tableName, &callbackFn); err != nil { + return err + } + + callFn, hasCallFn := object.GetCallFunc(ctx) + if !hasCallFn { + return object.NewError(errors.New("no callFn found in context")) + } + + newHandler := func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error) { + newEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, newEnv) + + res, err := callFn(ctx, callbackFn, []object.Object{ + newItemProxy(newResultSetProxy(rs), index), + }) + + if err != nil { + return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, errObj.Inspect()) + } + + itr, objErr := object.AsIterator(res) + if err != nil { + return nil, objErr.Value() + } + + var relItems []relatedItem + for next, hasNext := itr.Next(ctx); hasNext; next, hasNext = itr.Next(ctx) { + var newRelItem relatedItem + + itemMap, objErr := object.AsMap(next) + if err != nil { + return nil, objErr.Value() + } + + labelName, objErr := object.AsString(itemMap.Get("label")) + if objErr != nil { + continue + } + newRelItem.label = labelName + + var tableStr = "" + if itemMap.Get("table") != object.Nil { + tableStr, objErr = object.AsString(itemMap.Get("table")) + if objErr != nil { + continue + } + } + newRelItem.table = tableStr + + if selectFn, ok := itemMap.Get("on_select").(*object.Function); ok { + newRelItem.onSelect = func() error { + thisNewEnv := thisEnv + ctx = ctxWithScriptEnv(ctx, thisNewEnv) + + res, err := callFn(ctx, selectFn, []object.Object{}) + if err != nil { + return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, err) + } else if object.IsError(res) { + errObj := res.(*object.Error) + return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, errObj.Inspect()) + } + return nil + } + } else { + queryExprStr, objErr := object.AsString(itemMap.Get("query")) + if objErr != nil { + continue + } + + query, err := queryexpr.Parse(queryExprStr) + if err != nil { + continue + } + + // Placeholders + if argsVal, isArgsValMap := object.AsMap(itemMap.Get("args")); isArgsValMap == nil { + namePlaceholders := make(map[string]string) + valuePlaceholders := make(map[string]types.AttributeValue) + + for k, val := range argsVal.Value() { + switch v := val.(type) { + case *object.String: + namePlaceholders[k] = v.Value() + valuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()} + case *object.Int: + valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Float: + valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Bool: + valuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()} + case *object.NilType: + valuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true} + default: + continue + } + } + + query = query.WithNameParams(namePlaceholders).WithValueParams(valuePlaceholders) + } + newRelItem.query = query + } + + relItems = append(relItems, newRelItem) + } + + return relItems, nil + } + + m.scriptPlugin.relatedItems = append(m.scriptPlugin.relatedItems, &relatedItemBuilder{ + table: tableName, + itemProduction: newHandler, + }) + + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/modext_test.go b/internal/dynamo-browse/services/scriptmanager/modext_test.go new file mode 100644 index 0000000..b3413e6 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modext_test.go @@ -0,0 +1,151 @@ +package scriptmanager_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/stretchr/testify/assert" +) + +func TestExtModule_RelatedItems(t *testing.T) { + t.Run("should register a function which will return related items for an item", func(t *testing.T) { + scenarios := []struct { + desc string + code string + }{ + { + desc: "single function, table name match", + code: ` + ext.related_items("test-table", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + { + desc: "single function, table prefix match", + code: ` + ext.related_items("test-*", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + { + desc: "multi function, table name match", + code: ` + ext.related_items("test-table", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + ] + }) + + ext.related_items("test-table", func(item) { + return [ + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + { + desc: "multi function, table name prefix", + code: ` + ext.related_items("test-*", func(item) { + print("Hello") + return [ + {"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}}, + ] + }) + + ext.related_items("test-*", func(item) { + return [ + {"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}}, + ] + }) + `, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.desc, func(t *testing.T) { + // Load the script + srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", scenario.code))) + + ctx := context.Background() + plugin, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + assert.NotNil(t, plugin) + + // Get related items of result set + rs := &models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "test-table", + }, + } + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0) + assert.NoError(t, err) + assert.Len(t, relItems, 2) + + assert.Equal(t, "Customer", relItems[0].Name) + assert.Equal(t, "pk=$foo", relItems[0].Query.String()) + assert.Equal(t, "foo", relItems[0].Query.ValueParamOrNil("foo").(*types.AttributeValueMemberS).Value) + + assert.Equal(t, "Payment", relItems[1].Name) + assert.Equal(t, "fla=$daa", relItems[1].Query.String()) + assert.Equal(t, "Hello", relItems[1].Query.ValueParamOrNil("daa").(*types.AttributeValueMemberS).Value) + }) + } + }) + + t.Run("should support rel_items with on select", func(t *testing.T) { + // Load the script + srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", ` + ext.related_items("test-table", func(item) { + print("Hello") + return [ + {"label": "Customer", "on_select": func() { + print("Selected") + }}, + ] + }) + `))) + + ctx := context.Background() + plugin, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + assert.NotNil(t, plugin) + + // Get related items of result set + rs := &models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "test-table", + }, + } + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0) + assert.NoError(t, err) + assert.Len(t, relItems, 1) + + assert.Equal(t, "Customer", relItems[0].Name) + assert.NoError(t, relItems[0].OnSelect()) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modos_test.go b/internal/dynamo-browse/services/scriptmanager/modos_test.go new file mode 100644 index 0000000..455125b --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modos_test.go @@ -0,0 +1,56 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func TestOSModule_Env(t *testing.T) { + t.Run("should return value of environment variables", func(t *testing.T) { + t.Setenv("FULL_VALUE", "this is a value") + t.Setenv("EMPTY_VALUE", "") + + testFS := testScriptFile(t, "test.tm", ` + assert(os.getenv("FULL_VALUE") == "this is a value") + assert(os.getenv("EMPTY_VALUE") == "") + assert(os.getenv("MISSING_VALUE") == "") + + assert(bool(os.getenv("FULL_VALUE")) == true) + assert(bool(os.getenv("EMPTY_VALUE")) == false) + assert(bool(os.getenv("MISSING_VALUE")) == false) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + }) +} + +func TestOSModule_Exec(t *testing.T) { + t.Run("should run command and return stdout", func(t *testing.T) { + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "hello world\n") + + testFS := testScriptFile(t, "test.tm", ` + res := exec('echo', ["hello world"]).stdout + ui.print(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modsession.go b/internal/dynamo-browse/services/scriptmanager/modsession.go new file mode 100644 index 0000000..95c16c7 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modsession.go @@ -0,0 +1,145 @@ +package scriptmanager + +import ( + "context" + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" +) + +type sessionModule struct { + sessionService SessionService +} + +func (um *sessionModule) query(ctx context.Context, args ...object.Object) object.Object { + if len(args) == 0 || len(args) > 2 { + return object.Errorf("type error: session.query takes either 1 or 2 arguments (%d given)", len(args)) + } + + var options QueryOptions + + expr, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + if len(args) == 2 { + objMap, objErr := object.AsMap(args[1]) + if objErr != nil { + return objErr + } + + // Table name + if val := objMap.Get("table"); val != object.Nil && val.IsTruthy() { + switch tv := val.(type) { + case *object.String: + options.TableName = tv.Value() + case *tableProxy: + options.TableName = tv.table.Name + default: + return object.Errorf("type error: query option 'table' must be either a string or table") + } + } + + // Index name + if val, isStr := objMap.Get("index").(*object.String); isStr { + options.IndexName = val.Value() + } + + // Placeholders + if argsVal, isArgsValMap := objMap.Get("args").(*object.Map); isArgsValMap { + options.NamePlaceholders = make(map[string]string) + options.ValuePlaceholders = make(map[string]types.AttributeValue) + + for k, val := range argsVal.Value() { + switch v := val.(type) { + case *object.String: + options.NamePlaceholders[k] = v.Value() + options.ValuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()} + case *object.Int: + options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Float: + options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())} + case *object.Bool: + options.ValuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()} + case *object.NilType: + options.ValuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true} + default: + return object.Errorf("type error: arg '%v' of type '%v' is not supported", k, val.Type()) + } + } + } + } + + resp, err := um.sessionService.Query(ctx, expr, options) + + if err != nil { + return object.NewError(err) + } + return &resultSetProxy{resultSet: resp} +} + +func (um *sessionModule) resultSet(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.result_set", 0, args); err != nil { + return err + } + + rs := um.sessionService.ResultSet(ctx) + if rs == nil { + return object.Nil + } + return &resultSetProxy{resultSet: rs} +} + +func (um *sessionModule) selectedItem(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.result_set", 0, args); err != nil { + return err + } + + rs := um.sessionService.ResultSet(ctx) + idx := um.sessionService.SelectedItemIndex(ctx) + if rs == nil || idx < 0 { + return object.Nil + } + + rsProxy := &resultSetProxy{resultSet: rs} + return newItemProxy(rsProxy, idx) +} + +func (um *sessionModule) setResultSet(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.set_result_set", 1, args); err != nil { + return err + } + + resultSetProxy, isResultSetProxy := args[0].(*resultSetProxy) + if !isResultSetProxy { + return object.NewError(errors.Errorf("type error: expected a resultsset (got %v)", args[0])) + } + + um.sessionService.SetResultSet(ctx, resultSetProxy.resultSet) + return nil +} + +func (um *sessionModule) currentTable(ctx context.Context, args ...object.Object) object.Object { + if err := require("session.current_table", 0, args); err != nil { + return err + } + + rs := um.sessionService.ResultSet(ctx) + if rs == nil { + return object.Nil + } + + return &tableProxy{table: rs.TableInfo} +} + +func (um *sessionModule) register() *object.Module { + return object.NewBuiltinsModule("session", map[string]object.Object{ + "query": object.NewBuiltin("query", um.query), + "current_table": object.NewBuiltin("current_table", um.currentTable), + "result_set": object.NewBuiltin("result_set", um.resultSet), + "selected_item": object.NewBuiltin("selected_item", um.selectedItem), + "set_result_set": object.NewBuiltin("set_result_set", um.setResultSet), + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modsession_test.go b/internal/dynamo-browse/services/scriptmanager/modsession_test.go new file mode 100644 index 0000000..8712cf5 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modsession_test.go @@ -0,0 +1,426 @@ +package scriptmanager_test + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func TestModSession_Table(t *testing.T) { + t.Run("should return details of the current table", func(t *testing.T) { + tableDef := models.TableInfo{ + Name: "test_table", + Keys: models.KeyAttribute{ + PartitionKey: "pk", + SortKey: "sk", + }, + GSIs: []models.TableGSI{ + { + Name: "index-1", + Keys: models.KeyAttribute{ + PartitionKey: "ipk", + SortKey: "isk", + }, + }, + }, + } + rs := models.ResultSet{TableInfo: &tableDef} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&rs) + + testFS := testScriptFile(t, "test.tm", ` + table := session.current_table() + + assert(table.name == "test_table") + assert(table.keys["hash"] == "pk") + assert(table.keys["range"] == "sk") + assert(len(table.gsis) == 1) + assert(table.gsis[0].name == "index-1") + assert(table.gsis[0].keys["hash"] == "ipk") + assert(table.gsis[0].keys["range"] == "isk") + + assert(table == session.result_set().table) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return nil if no current result set", func(t *testing.T) { + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(nil) + + testFS := testScriptFile(t, "test.tm", ` + table := session.current_table() + + assert(table == nil) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestModSession_Query(t *testing.T) { + t.Run("should successfully return query result", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "2") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[0]['pk'].S = abc") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1]['pk'].S = 1232") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1].attr('size(pk)') = 4") + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + ui.print(res.length) + ui.print("res[0]['pk'].S = ", res[0].attr("pk")) + ui.print("res[1]['pk'].S = ", res[1].attr("pk")) + ui.print("res[1].attr('size(pk)') = ", res[1].attr("size(pk)")) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return error if query returns error", func(t *testing.T) { + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(nil, errors.New("bang")) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.Error(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should successfully specify table name", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{ + TableName: "some-table", + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr", { + table: "some-table", + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should successfully specify table proxy", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "some-resultset-table", + }, + }) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{ + TableName: "some-resultset-table", + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr", { + table: session.result_set().table, + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should set placeholder values", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{ + NamePlaceholders: map[string]string{ + "name": "hello", + "value": "world", + }, + ValuePlaceholders: map[string]types.AttributeValue{ + "name": &types.AttributeValueMemberS{Value: "hello"}, + "value": &types.AttributeValueMemberS{Value: "world"}, + }, + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query(":name = $value", { + args: { + name: "hello", + value: "world", + }, + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should support various placeholder value type", func(t *testing.T) { + rs := &models.ResultSet{} + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{ + NamePlaceholders: map[string]string{ + "str": "hello", + }, + ValuePlaceholders: map[string]types.AttributeValue{ + "str": &types.AttributeValueMemberS{Value: "hello"}, + "int": &types.AttributeValueMemberN{Value: "123"}, + "float": &types.AttributeValueMemberN{Value: "3.14"}, + "bool": &types.AttributeValueMemberBOOL{Value: true}, + "nil": &types.AttributeValueMemberNULL{Value: true}, + }, + }).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query(":name = $value", { + args: { + "str": "hello", + "int": 123, + "float": 3.14, + "bool": true, + "nil": nil, + }, + }) + assert(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return error when placeholder value type is unsupported", func(t *testing.T) { + mockedSessionService := mocks.NewSessionService(t) + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query(":name = $value", { + args: { + "bad": func() { }, + }, + }) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.Error(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} + +func TestModSession_SelectedItem(t *testing.T) { + t.Run("should return selected item from service implementation", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs) + mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(1) + + testFS := testScriptFile(t, "test.tm", ` + selItem := session.selected_item() + + assert(selItem != nil, "selItem != nil") + assert(selItem.index == 1, "selItem.index") + assert(selItem.result_set == session.result_set(), "selItem.result_set") + assert(selItem.attr('pk') == '1232', "selItem.attr('pk')") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return nil if selected item returns -1", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs) + mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(-1) + + testFS := testScriptFile(t, "test.tm", ` + selItem := session.selected_item() + + assert(selItem == nil, "selItem != nil") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestModSession_SetResultSet(t *testing.T) { + t.Run("should set the result set on the session", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + mockedSessionService.EXPECT().SetResultSet(mock.Anything, rs) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modui.go b/internal/dynamo-browse/services/scriptmanager/modui.go new file mode 100644 index 0000000..d53b2e4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modui.go @@ -0,0 +1,58 @@ +package scriptmanager + +import ( + "context" + "strings" + + "github.com/risor-io/risor/object" +) + +type uiModule struct { + uiService UIService +} + +func (um *uiModule) print(ctx context.Context, args ...object.Object) object.Object { + var msg strings.Builder + for _, arg := range args { + if arg == nil { + continue + } + + switch a := arg.(type) { + case *object.String: + msg.WriteString(a.Value()) + default: + msg.WriteString(a.Inspect()) + } + } + + um.uiService.PrintMessage(ctx, msg.String()) + return object.Nil +} + +func (um *uiModule) prompt(ctx context.Context, args ...object.Object) object.Object { + if err := require("ui.prompt", 1, args); err != nil { + return err + } + + msg, _ := object.AsString(args[0]) + respChan := um.uiService.Prompt(ctx, msg) + + select { + case resp, hasResp := <-respChan: + if hasResp { + return object.NewString(resp) + } else { + return object.Nil + } + case <-ctx.Done(): + return object.NewError(ctx.Err()) + } +} + +func (um *uiModule) register() *object.Module { + return object.NewBuiltinsModule("ui", map[string]object.Object{ + "print": object.NewBuiltin("print", um.print), + "prompt": object.NewBuiltin("prompt", um.prompt), + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modui_test.go b/internal/dynamo-browse/services/scriptmanager/modui_test.go new file mode 100644 index 0000000..3a8b96d --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modui_test.go @@ -0,0 +1,100 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func TestModUI_Prompt(t *testing.T) { + t.Run("should successfully return prompt value", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + var name = ui.prompt("What is your name? ") + ui.print("Hello, " + name) + `) + + promptChan := make(chan string) + go func() { + promptChan <- "T. Test" + }() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, T. Test") + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) + + t.Run("should return nil if prompt was cancelled", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + var name = ui.prompt("What is your name? ") + ui.print("After") + ui.print(nil) + `) + + promptChan := make(chan string) + close(promptChan) + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "After") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "nil") + mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) + + t.Run("should return error if context was cancelled", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + var name = ui.prompt("What is your name? ") + ui.print("After") + `) + + promptChan := make(chan string) + ctx, cancelFn := context.WithCancel(context.Background()) + defer cancelFn() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Run(func(ctx context.Context, msg string) { + cancelFn() + }).Return(promptChan) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.Error(t, err) + + mockedUIService.AssertNotCalled(t, "Prompt", "after") + mockedUIService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/opts.go b/internal/dynamo-browse/services/scriptmanager/opts.go new file mode 100644 index 0000000..39d961f --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/opts.go @@ -0,0 +1,26 @@ +package scriptmanager + +import ( + "context" + "github.com/risor-io/risor/limits" +) + +// scriptEnv is the runtime environment for a particular script execution +type scriptEnv struct { + filename string +} + +type scriptEnvKeyType struct{} + +var scriptEnvKey = scriptEnvKeyType{} + +func scriptEnvFromCtx(ctx context.Context) scriptEnv { + perms, _ := ctx.Value(scriptEnvKey).(scriptEnv) + return perms +} + +func ctxWithScriptEnv(ctx context.Context, perms scriptEnv) context.Context { + newCtx := context.WithValue(ctx, scriptEnvKey, perms) + newCtx = limits.WithLimits(newCtx, limits.New()) + return newCtx +} diff --git a/internal/dynamo-browse/services/scriptmanager/relitem.go b/internal/dynamo-browse/services/scriptmanager/relitem.go new file mode 100644 index 0000000..63d7629 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/relitem.go @@ -0,0 +1,57 @@ +package scriptmanager + +import ( + "context" + "log" + "path" + + "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/models/relitems" +) + +type relatedItem struct { + label string + table string + query *queryexpr.QueryExpr + onSelect func() error +} + +type relatedItemBuilder struct { + table string + itemProduction func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error) +} + +func (s *Service) RelatedItemOfItem(ctx context.Context, rs *models.ResultSet, index int) ([]relitems.RelatedItem, error) { + riModels := []relitems.RelatedItem{} + + for _, plugin := range s.plugins { + for _, rb := range plugin.relatedItems { + // TODO: should support matching + match, _ := tableMatchesGlob(rb.table, rs.TableInfo.Name) + log.Printf("RelatedItemOfItem: table = '%v', pattern = '%v', match = '%v'", rb.table, rs.TableInfo.Name, match) + if match { + relatedItems, err := rb.itemProduction(ctx, rs, index) + if err != nil { + // TODO: should probably return error if no rel items were found and an error was raised + return nil, err + } + + // TODO: make this nicer + for _, ri := range relatedItems { + riModels = append(riModels, relitems.RelatedItem{ + Name: ri.label, + Query: ri.query, + Table: ri.table, + OnSelect: ri.onSelect, + }) + } + } + } + } + return riModels, nil +} + +func tableMatchesGlob(tableName, pattern string) (bool, error) { + return path.Match(tableName, pattern) +} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go new file mode 100644 index 0000000..953a3e4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go @@ -0,0 +1,337 @@ +package scriptmanager + +import ( + "context" + "time" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/attrutils" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" + "github.com/risor-io/risor/op" +) + +type resultSetProxy struct { + resultSet *models.ResultSet +} + +func newResultSetProxy(rs *models.ResultSet) *resultSetProxy { + return &resultSetProxy{resultSet: rs} +} + +func (r *resultSetProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (r *resultSetProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (r *resultSetProxy) Cost() int { + return len(r.resultSet.Items()) +} + +func (r *resultSetProxy) Interface() interface{} { + return r.resultSet +} + +func (r *resultSetProxy) IsTruthy() bool { + return true +} + +func (r *resultSetProxy) Type() object.Type { + return "resultset" +} + +func (r *resultSetProxy) Inspect() string { + return "resultset" +} + +func (r *resultSetProxy) Equals(other object.Object) object.Object { + otherRS, isOtherRS := other.(*resultSetProxy) + if !isOtherRS { + return object.False + } + + return object.NewBool(r.resultSet == otherRS.resultSet) +} + +// GetItem implements the [key] operator for a container type. +func (r *resultSetProxy) GetItem(key object.Object) (object.Object, *object.Error) { + idx, err := object.AsInt(key) + if err != nil { + return nil, err + } + + realIdx := int(idx) + if realIdx < 0 { + realIdx = len(r.resultSet.Items()) + realIdx + } + + if realIdx < 0 || realIdx >= len(r.resultSet.Items()) { + return nil, object.NewError(errors.Errorf("index error: index out of range: %v", idx)) + } + + return newItemProxy(r, realIdx), nil +} + +// GetSlice implements the [start:stop] operator for a container type. +func (r *resultSetProxy) GetSlice(s object.Slice) (object.Object, *object.Error) { + return nil, object.NewError(errors.New("TODO")) +} + +// SetItem implements the [key] = value operator for a container type. +func (r *resultSetProxy) SetItem(key, value object.Object) *object.Error { + return object.NewError(errors.New("TODO")) +} + +// DelItem implements the del [key] operator for a container type. +func (r *resultSetProxy) DelItem(key object.Object) *object.Error { + return object.NewError(errors.New("TODO")) +} + +// Contains returns true if the given item is found in this container. +func (r *resultSetProxy) Contains(item object.Object) *object.Bool { + // TODO + return object.False +} + +// Len returns the number of items in this container. +func (r *resultSetProxy) Len() *object.Int { + return object.NewInt(int64(len(r.resultSet.Items()))) +} + +// Iter returns an iterator for this container. +func (r *resultSetProxy) Iter() object.Iterator { + // TODO + return nil +} + +func (r *resultSetProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "table": + return &tableProxy{table: r.resultSet.TableInfo}, true + case "length": + return object.NewInt(int64(len(r.resultSet.Items()))), true + case "find": + return object.NewBuiltin("find", r.find), true + case "merge": + return object.NewBuiltin("merge", r.merge), true + } + + return nil, false +} + +func (i *resultSetProxy) find(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("resultset.find", 1, args); objErr != nil { + return objErr + } + + str, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + modExpr, err := queryexpr.Parse(str) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + + for idx, item := range i.resultSet.Items() { + rs, err := modExpr.EvalItem(item) + if err != nil { + continue + } + + if attrutils.Truthy(rs) { + return newItemProxy(i, idx) + } + } + + return object.Nil +} + +func (i *resultSetProxy) merge(ctx context.Context, args ...object.Object) object.Object { + type pksk struct { + pk types.AttributeValue + sk types.AttributeValue + } + + if objErr := require("resultset.merge", 1, args); objErr != nil { + return objErr + } + + otherRS, isRS := args[0].(*resultSetProxy) + if !isRS { + return object.NewError(errors.Errorf("type error: expected a resultset (got %v)", args[0].Type())) + } + + if !i.resultSet.TableInfo.Equal(otherRS.resultSet.TableInfo) { + return object.Nil + } + + itemsInI := make(map[pksk]models.Item) + newItems := make([]models.Item, 0, len(i.resultSet.Items())+len(otherRS.resultSet.Items())) + for _, item := range i.resultSet.Items() { + pk, sk := item.PKSK(i.resultSet.TableInfo) + itemsInI[pksk{pk, sk}] = item + newItems = append(newItems, item) + } + + for _, item := range otherRS.resultSet.Items() { + pk, sk := item.PKSK(i.resultSet.TableInfo) + if _, hasItem := itemsInI[pksk{pk, sk}]; !hasItem { + newItems = append(newItems, item) + } + } + + newResultSet := &models.ResultSet{ + Created: time.Now(), + TableInfo: i.resultSet.TableInfo, + } + newResultSet.SetItems(newItems) + + return &resultSetProxy{resultSet: newResultSet} +} + +type itemProxy struct { + resultSetProxy *resultSetProxy + itemIndex int + item models.Item +} + +func (i *itemProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (i *itemProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (i *itemProxy) Cost() int { + return len(i.item) +} + +func newItemProxy(rs *resultSetProxy, itemIndex int) *itemProxy { + return &itemProxy{ + resultSetProxy: rs, + itemIndex: itemIndex, + item: rs.resultSet.Items()[itemIndex], + } +} + +func (i *itemProxy) Interface() interface{} { + return i.item +} + +func (i *itemProxy) IsTruthy() bool { + return true +} + +func (i *itemProxy) Type() object.Type { + return "item" +} + +func (i *itemProxy) Inspect() string { + return "item" +} + +func (i *itemProxy) Equals(other object.Object) object.Object { + // TODO + return object.False +} + +func (i *itemProxy) GetAttr(name string) (object.Object, bool) { + // TODO: this should implement the container interface + switch name { + case "result_set": + return i.resultSetProxy, true + case "index": + return object.NewInt(int64(i.itemIndex)), true + case "attr": + return object.NewBuiltin("attr", i.value), true + case "set_attr": + return object.NewBuiltin("set_attr", i.setValue), true + case "delete_attr": + return object.NewBuiltin("delete_attr", i.deleteAttr), true + } + + return nil, false +} + +func (i *itemProxy) value(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("item.attr", 1, args); objErr != nil { + return objErr + } + + str, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + modExpr, err := queryexpr.Parse(str) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + av, err := modExpr.EvalItem(i.item) + if err != nil { + return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err)) + } + + tVal, err := attributeValueToTamarin(av) + if err != nil { + return object.NewError(err) + } + return tVal +} + +func (i *itemProxy) setValue(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("item.set_attr", 2, args); objErr != nil { + return objErr + } + + pathExpr, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + path, err := queryexpr.Parse(pathExpr) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + + newValue, err := tamarinValueToAttributeValue(args[1]) + if err != nil { + return object.NewError(err) + } + if err := path.SetEvalItem(i.item, newValue); err != nil { + return object.NewError(err) + } + + i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true) + return nil +} + +func (i *itemProxy) deleteAttr(ctx context.Context, args ...object.Object) object.Object { + if objErr := require("item.delete_attr", 1, args); objErr != nil { + return objErr + } + + str, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + modExpr, err := queryexpr.Parse(str) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + if err := modExpr.DeleteAttribute(i.item); err != nil { + return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err)) + } + + i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true) + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go new file mode 100644 index 0000000..3b7f354 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go @@ -0,0 +1,355 @@ +package scriptmanager_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestResultSetProxy(t *testing.T) { + t.Run("should property return properties of a resultset and item", func(t *testing.T) { + rs := &models.ResultSet{ + TableInfo: &models.TableInfo{ + Name: "test-table", + }, + } + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + // Test properties of the result set + assert(res.table.name, "hello") + + assert(res == res, "result_set.equals") + assert(res.length == 2, "result_set.length") + + // Test properties of items + assert(res[0].index == 0, "res[0].index") + assert(res[0].result_set == res, "res[0].result_set") + assert(res[0].attr('pk') == 'abc', "res[0].attr('pk')") + + assert(res[1].attr('pk') == '1232', "res[1].attr('pk')") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_Find(t *testing.T) { + t.Run("should return the first item that matches the given expression", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "abc"}, "primary": &types.AttributeValueMemberS{Value: "yes"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}, "findMe": &types.AttributeValueMemberS{Value: "yes"}}, + {"pk": &types.AttributeValueMemberS{Value: "2345"}, "findMe": &types.AttributeValueMemberS{Value: "second"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + assert(res.find('findMe is "any"').attr("pk") == "1232") + assert(res.find('findMe = "second"').attr("pk") == "2345") + assert(res.find('pk = sk').attr("primary") == "yes") + + assert(res.find('findMe = "missing"') == nil) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_Merge(t *testing.T) { + t.Run("should return a result set with items from both if both are from the same table", func(t *testing.T) { + td := &models.TableInfo{Name: "test", Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}} + + rs1 := &models.ResultSet{TableInfo: td} + rs1.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "123"}}, + }) + + rs2 := &models.ResultSet{TableInfo: td} + rs2.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "bcd"}, "sk": &types.AttributeValueMemberS{Value: "234"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "rs1", scriptmanager.QueryOptions{}).Return(rs1, nil) + mockedSessionService.EXPECT().Query(mock.Anything, "rs2", scriptmanager.QueryOptions{}).Return(rs2, nil) + + testFS := testScriptFile(t, "test.tm", ` + r1 := session.query("rs1") + r2 := session.query("rs2") + + res := r1.merge(r2) + + assert(res[0].attr("pk") == "abc") + assert(res[0].attr("sk") == "123") + assert(res[1].attr("pk") == "bcd") + assert(res[1].attr("sk") == "234") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) + + t.Run("should return nil if result-sets are from different tables", func(t *testing.T) { + td1 := &models.TableInfo{Name: "test", Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}} + rs1 := &models.ResultSet{TableInfo: td1} + rs1.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "123"}}, + }) + + td2 := &models.TableInfo{Name: "test2", Keys: models.KeyAttribute{PartitionKey: "pk2", SortKey: "sk"}} + rs2 := &models.ResultSet{TableInfo: td2} + rs2.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "bcd"}, "sk": &types.AttributeValueMemberS{Value: "234"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "rs1", scriptmanager.QueryOptions{}).Return(rs1, nil) + mockedSessionService.EXPECT().Query(mock.Anything, "rs2", scriptmanager.QueryOptions{}).Return(rs2, nil) + + testFS := testScriptFile(t, "test.tm", ` + r1 := session.query("rs1") + r2 := session.query("rs2") + + res := r1.merge(r2) + + assert(res == nil) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_GetAttr(t *testing.T) { + t.Run("should return the value of items within a result set", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + { + "pk": &types.AttributeValueMemberS{Value: "abc"}, + "sk": &types.AttributeValueMemberN{Value: "123"}, + "bool": &types.AttributeValueMemberBOOL{Value: true}, + "null": &types.AttributeValueMemberNULL{Value: true}, + "list": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: "apple"}, + &types.AttributeValueMemberS{Value: "banana"}, + &types.AttributeValueMemberS{Value: "cherry"}, + }}, + "map": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "this": &types.AttributeValueMemberS{Value: "that"}, + "another": &types.AttributeValueMemberS{Value: "thing"}, + }}, + "strSet": &types.AttributeValueMemberSS{Value: []string{"apple", "banana", "cherry"}}, + "numSet": &types.AttributeValueMemberNS{Value: []string{"123", "45.67", "8.911", "-321"}}, + }, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + assert(res[0].attr("pk") == "abc", "str attr") + assert(res[0].attr("sk") == 123, "num attr") + assert(res[0].attr("bool") == true, "bool attr") + assert(res[0].attr("null") == nil, "null attr") + assert(res[0].attr("list") == ["apple","banana","cherry"], "list attr") + assert(res[0].attr("map") == {"this":"that", "another":"thing"}, "map attr") + assert(res[0].attr("strSet") == {"apple","banana","cherry"}, "string set") + assert(res[0].attr("numSet") == {123, 45.67, 8.911, -321}, "number set") + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_SetAttr(t *testing.T) { + t.Run("should set the value of the item within a result set", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool { + assert.Equal(t, "bla-di-bla", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "123", rs.Items()[0]["num"].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "123.45", rs.Items()[0]["numFloat"].(*types.AttributeValueMemberN).Value) + assert.Equal(t, true, rs.Items()[0]["bool"].(*types.AttributeValueMemberBOOL).Value) + assert.Equal(t, true, rs.Items()[0]["nil"].(*types.AttributeValueMemberNULL).Value) + + list := rs.Items()[0]["lists"].(*types.AttributeValueMemberL).Value + assert.Equal(t, "abc", list[0].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "123", list[1].(*types.AttributeValueMemberN).Value) + assert.Equal(t, true, list[2].(*types.AttributeValueMemberBOOL).Value) + + nestedLists := rs.Items()[0]["nestedLists"].(*types.AttributeValueMemberL).Value + assert.Equal(t, "1", nestedLists[0].(*types.AttributeValueMemberL).Value[0].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "2", nestedLists[0].(*types.AttributeValueMemberL).Value[1].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "3", nestedLists[1].(*types.AttributeValueMemberL).Value[0].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "4", nestedLists[1].(*types.AttributeValueMemberL).Value[1].(*types.AttributeValueMemberN).Value) + + mapValue := rs.Items()[0]["map"].(*types.AttributeValueMemberM).Value + assert.Equal(t, "world", mapValue["hello"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "213", mapValue["nums"].(*types.AttributeValueMemberN).Value) + + numSet := rs.Items()[0]["numSet"].(*types.AttributeValueMemberNS).Value + assert.Len(t, numSet, 4) + assert.Contains(t, numSet, "1") + assert.Contains(t, numSet, "2") + assert.Contains(t, numSet, "3") + assert.Contains(t, numSet, "4.5") + + strSet := rs.Items()[0]["strSet"].(*types.AttributeValueMemberSS).Value + assert.Len(t, strSet, 3) + assert.Contains(t, strSet, "a") + assert.Contains(t, strSet, "b") + assert.Contains(t, strSet, "c") + + assert.True(t, rs.IsDirty(0)) + return true + })) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + + res[0].set_attr("pk", "bla-di-bla") + res[0].set_attr("num", 123) + res[0].set_attr("numFloat", 123.45) + res[0].set_attr("bool", true) + res[0].set_attr("nil", nil) + res[0].set_attr("lists", ['abc', 123, true]) + res[0].set_attr("nestedLists", [[1,2], [3,4]]) + res[0].set_attr("map", {"hello": "world", "nums": 213}) + res[0].set_attr("numSet", {1,2,3,4.5}) + res[0].set_attr("strSet", {"a","b","c"}) + + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} + +func TestResultSetProxy_DeleteAttr(t *testing.T) { + t.Run("should delete the value of the item within a result set", func(t *testing.T) { + rs := &models.ResultSet{} + rs.SetItems([]models.Item{ + {"pk": &types.AttributeValueMemberS{Value: "abc"}, "deleteMe": &types.AttributeValueMemberBOOL{Value: true}}, + {"pk": &types.AttributeValueMemberS{Value: "1232"}}, + }) + + mockedSessionService := mocks.NewSessionService(t) + mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil) + mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool { + assert.Equal(t, "abc", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value) + assert.Nil(t, rs.Items()[0]["deleteMe"]) + assert.True(t, rs.IsDirty(0)) + return true + })) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + res[0].delete_attr("deleteMe") + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + +} diff --git a/internal/dynamo-browse/services/scriptmanager/scrsched.go b/internal/dynamo-browse/services/scriptmanager/scrsched.go new file mode 100644 index 0000000..3846676 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/scrsched.go @@ -0,0 +1,53 @@ +package scriptmanager + +import ( + "context" + "github.com/pkg/errors" + "time" +) + +type scriptScheduler struct { + jobChan chan scriptJob +} + +func newScriptScheduler() *scriptScheduler { + ss := &scriptScheduler{} + ss.start() + return ss +} + +func (ss *scriptScheduler) start() { + ss.jobChan = make(chan scriptJob) + go func() { + for job := range ss.jobChan { + job.job(job.ctx) + } + }() +} + +// startJobOnceFree will submit a script execution job. The function will wait until the scheduler is free. +// The job will then run on the script goroutine and the function will return. +func (ss *scriptScheduler) startJobOnceFree(ctx context.Context, job func(ctx context.Context)) error { + select { + case ss.jobChan <- scriptJob{ctx: ctx, job: job}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// runNow will submit a job for immediate execution. The job will run as long as the scheduler is free. +// If the scheduler is not free, an error will be returned and the job will not run. +func (ss *scriptScheduler) runNow(ctx context.Context, job func(ctx context.Context)) error { + select { + case ss.jobChan <- scriptJob{ctx: ctx, job: job}: + return nil + case <-time.After(500 * time.Millisecond): + return errors.New("a script is already running") + } +} + +type scriptJob struct { + ctx context.Context + job func(ctx context.Context) +} diff --git a/internal/dynamo-browse/services/scriptmanager/service.go b/internal/dynamo-browse/services/scriptmanager/service.go new file mode 100644 index 0000000..eac6638 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/service.go @@ -0,0 +1,250 @@ +package scriptmanager + +import ( + "context" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings" + "github.com/pkg/errors" + "github.com/risor-io/risor" + "github.com/risor-io/risor/object" +) + +var ( + relPrefix = "." + string(filepath.Separator) +) + +type Service struct { + lookupPaths []fs.FS + ifaces Ifaces + sched *scriptScheduler + plugins []*ScriptPlugin +} + +func New(opts ...ServiceOption) *Service { + srv := &Service{ + lookupPaths: nil, + sched: newScriptScheduler(), + } + for _, opt := range opts { + opt(srv) + } + return srv +} + +func (s *Service) SetLookupPaths(fs []fs.FS) { + s.lookupPaths = fs +} + +func (s *Service) SetIFaces(ifaces Ifaces) { + s.ifaces = ifaces +} + +func (s *Service) LoadScript(ctx context.Context, filename string) (*ScriptPlugin, error) { + resChan := make(chan loadedScriptResult) + + if err := s.sched.startJobOnceFree(ctx, func(ctx context.Context) { + s.loadScript(ctx, filename, resChan) + }); err != nil { + return nil, err + } + + res := <-resChan + if res.err != nil { + return nil, res.err + } + + // Look for the previous version. If one is there, replace it, otherwise add it + // TODO: this should probably be protected by a mutex + newPlugin := res.scriptPlugin + for i, p := range s.plugins { + if p.name == newPlugin.name { + s.plugins[i] = newPlugin + return newPlugin, nil + } + } + + s.plugins = append(s.plugins, newPlugin) + return newPlugin, nil +} + +func (s *Service) RunAdHocScript(ctx context.Context, filename string) chan error { + errChan := make(chan error) + go s.startAdHocScript(ctx, filename, errChan) + return errChan +} + +func (s *Service) StartAdHocScript(ctx context.Context, filename string, errChan chan error) error { + return s.sched.startJobOnceFree(ctx, func(ctx context.Context) { + s.startAdHocScript(ctx, filename, errChan) + }) +} + +func (s *Service) startAdHocScript(ctx context.Context, filename string, errChan chan error) { + defer close(errChan) + + code, err := s.readScript(filename, true) + if err != nil { + errChan <- errors.Wrapf(err, "cannot load script file %v", filename) + return + } + + ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename)}) + + if _, err := risor.Eval(ctx, code, + risor.WithGlobals(s.builtins()), + ); err != nil { + errChan <- errors.Wrapf(err, "script %v", filename) + return + } +} + +type loadedScriptResult struct { + scriptPlugin *ScriptPlugin + err error +} + +func (s *Service) loadScript(ctx context.Context, filename string, resChan chan loadedScriptResult) { + defer close(resChan) + + code, err := s.readScript(filename, false) + if err != nil { + resChan <- loadedScriptResult{err: errors.Wrapf(err, "cannot load script file %v", filename)} + return + } + + newPlugin := &ScriptPlugin{ + name: strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)), + scriptService: s, + } + + ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename)}) + + if _, err := risor.Eval(ctx, code, + risor.WithGlobals(s.builtins()), + risor.WithGlobals(map[string]any{ + "ext": (&extModule{scriptPlugin: newPlugin}).register(), + }), + ); err != nil { + resChan <- loadedScriptResult{err: errors.Wrapf(err, "script %v", filename)} + return + } + + resChan <- loadedScriptResult{scriptPlugin: newPlugin} +} + +func (s *Service) readScript(filename string, allowCwd bool) (string, error) { + if allowCwd { + if cwd, err := os.Getwd(); err == nil { + fullScriptPath := filepath.Join(cwd, filename) + log.Printf("checking %v", fullScriptPath) + if stat, err := os.Stat(fullScriptPath); err == nil && !stat.IsDir() { + code, err := os.ReadFile(filename) + if err != nil { + return "", err + } + return string(code), nil + } + } else { + log.Printf("warn: cannot get cwd for reading script %v: %v", filename, err) + } + } + + if strings.HasPrefix(filename, string(filepath.Separator)) || strings.HasPrefix(filename, relPrefix) { + code, err := os.ReadFile(filename) + if err != nil { + return "", err + } + return string(code), nil + } + + for _, currFS := range s.lookupPaths { + log.Printf("checking %v/%v", currFS, filename) + stat, err := fs.Stat(currFS, filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } else { + return "", err + } + } else if stat.IsDir() { + continue + } + + code, err := fs.ReadFile(currFS, filename) + if err == nil { + return string(code), nil + } else { + return "", err + } + } + + return "", os.ErrNotExist +} + +// LookupCommand looks up a command defined by a script. +// TODO: Command should probably accept/return a chan error to indicate that this will run in a separate goroutine +func (s *Service) LookupCommand(name string) *Command { + for _, p := range s.plugins { + if cmd, hasCmd := p.definedCommands[name]; hasCmd { + return cmd + } + } + return nil +} + +func (s *Service) LookupKeyBinding(key string) (string, *Command) { + for _, p := range s.plugins { + if bindingName, hasBinding := p.keyToKeyBinding[key]; hasBinding { + if cmd, hasCmd := p.definedKeyBindings[bindingName]; hasCmd { + return bindingName, cmd + } + } + } + return "", nil +} + +func (s *Service) UnbindKey(key string) { + for _, p := range s.plugins { + if _, hasBinding := p.keyToKeyBinding[key]; hasBinding { + delete(p.keyToKeyBinding, key) + } + } +} + +func (s *Service) RebindKeyBinding(keyBinding string, newKey string) error { + if newKey == "" { + for _, p := range s.plugins { + for k, b := range p.keyToKeyBinding { + if b == keyBinding { + delete(p.keyToKeyBinding, k) + } + } + } + return nil + } + + for _, p := range s.plugins { + if _, hasCmd := p.definedKeyBindings[keyBinding]; hasCmd { + if newKey != "" { + p.keyToKeyBinding[newKey] = keyBinding + } + return nil + } + } + + return keybindings.InvalidBindingError(keyBinding) +} + +func (s *Service) builtins() map[string]any { + return map[string]any{ + "ui": (&uiModule{uiService: s.ifaces.UI}).register(), + "session": (&sessionModule{sessionService: s.ifaces.Session}).register(), + "print": object.NewBuiltin("print", printBuiltin), + "printf": object.NewBuiltin("printf", printfBuiltin), + } +} diff --git a/internal/dynamo-browse/services/scriptmanager/service_test.go b/internal/dynamo-browse/services/scriptmanager/service_test.go new file mode 100644 index 0000000..745124d --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/service_test.go @@ -0,0 +1,150 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "io/fs" + "testing" + "testing/fstest" + "time" +) + +func TestService_RunAdHocScript(t *testing.T) { + t.Run("successfully loads and executes a script", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + `) + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + }) +} + +func TestService_LoadScript(t *testing.T) { + t.Run("successfully loads a script and exposes it as a plugin", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ext.command("somewhere", func(a) { + ui.print("Hello, " + a) + }) + `) + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone") + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + plugin, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + assert.NotNil(t, plugin) + assert.Equal(t, "test", plugin.Name()) + + cmd := srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan := make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + mockedUIService.AssertExpectations(t) + }) + + t.Run("reloading a script with the same name should remove the old one", func(t *testing.T) { + testFS := fstest.MapFS{ + "test.tm": &fstest.MapFile{ + Data: []byte(` + ext.command("somewhere", func(a) { + ui.print("Hello, " + a) + }) + `), + }, + } + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone").Once() + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Goodbye, someone").Once() + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + // Execute the old script + _, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + + cmd := srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan := make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + // Change the script and reload + testFS["test.tm"] = &fstest.MapFile{ + Data: []byte(` + ext.command("somewhere", func(a) { + ui.print("Goodbye, " + a) + }) + `), + } + + _, err = srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + + cmd = srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan = make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + mockedUIService.AssertExpectations(t) + }) +} + +func testScriptFile(t *testing.T, filename, code string) fs.FS { + t.Helper() + + testFs := fstest.MapFS{ + filename: &fstest.MapFile{ + Data: []byte(code), + }, + } + return testFs +} + +func waitForErr(t *testing.T, errChan chan error) error { + t.Helper() + + select { + case err := <-errChan: + return err + case <-time.After(5 * time.Second): + t.Fatalf("timed-out waiting for an error") + } + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/serviceopts.go b/internal/dynamo-browse/services/scriptmanager/serviceopts.go new file mode 100644 index 0000000..6841531 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/serviceopts.go @@ -0,0 +1,11 @@ +package scriptmanager + +import "io/fs" + +type ServiceOption func(srv *Service) + +func WithFS(fs ...fs.FS) ServiceOption { + return func(srv *Service) { + srv.lookupPaths = fs + } +} diff --git a/internal/dynamo-browse/services/scriptmanager/tableproxy.go b/internal/dynamo-browse/services/scriptmanager/tableproxy.go new file mode 100644 index 0000000..252348f --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/tableproxy.go @@ -0,0 +1,138 @@ +package scriptmanager + +import ( + "github.com/lmika/dynamo-browse/internal/common/sliceutils" + "github.com/lmika/dynamo-browse/internal/dynamo-browse/models" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" + "github.com/risor-io/risor/op" + "reflect" +) + +const ( + tableProxyPartitionKey = "hash" + tableProxySortKey = "range" +) + +type tableProxy struct { + table *models.TableInfo +} + +func (t *tableProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (t *tableProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (t *tableProxy) Cost() int { + return 0 +} + +func (t *tableProxy) Type() object.Type { + return "table" +} + +func (t *tableProxy) Inspect() string { + return "table(" + t.table.Name + ")" +} + +func (t *tableProxy) Interface() interface{} { + return t.table +} + +func (t *tableProxy) Equals(other object.Object) object.Object { + otherT, isOtherRS := other.(*tableProxy) + if !isOtherRS { + return object.False + } + + return object.NewBool(reflect.DeepEqual(t.table, otherT.table)) +} + +func (t *tableProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "name": + return object.NewString(t.table.Name), true + case "keys": + if t.table.Keys.SortKey == "" { + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.table.Keys.PartitionKey), + tableProxySortKey: object.Nil, + }), true + } + + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.table.Keys.PartitionKey), + tableProxySortKey: object.NewString(t.table.Keys.SortKey), + }), true + case "gsis": + return object.NewList(sliceutils.Map(t.table.GSIs, newTableIndexProxy)), true + } + + return nil, false +} + +func (t *tableProxy) IsTruthy() bool { + return true +} + +type tableIndexProxy struct { + gsi models.TableGSI +} + +func (t tableIndexProxy) SetAttr(name string, value object.Object) error { + return errors.Errorf("attribute error: %v", name) +} + +func (t tableIndexProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object { + return object.Errorf("op error: unsupported %v", opType) +} + +func (t tableIndexProxy) Cost() int { + return 0 +} + +func newTableIndexProxy(gsi models.TableGSI) object.Object { + return tableIndexProxy{gsi: gsi} +} + +func (t tableIndexProxy) Type() object.Type { + return "table_index" +} + +func (t tableIndexProxy) Inspect() string { + return "table_index(gsi," + t.gsi.Name + ")" +} + +func (t tableIndexProxy) Interface() interface{} { + return t.gsi +} + +func (t tableIndexProxy) Equals(other object.Object) object.Object { + otherIP, isOtherIP := other.(tableIndexProxy) + if !isOtherIP { + return object.False + } + + return object.NewBool(reflect.DeepEqual(t.gsi, otherIP.gsi)) +} + +func (t tableIndexProxy) GetAttr(name string) (object.Object, bool) { + switch name { + case "name": + return object.NewString(t.gsi.Name), true + case "keys": + return object.NewMap(map[string]object.Object{ + tableProxyPartitionKey: object.NewString(t.gsi.Keys.PartitionKey), + tableProxySortKey: object.NewString(t.gsi.Keys.SortKey), + }), true + } + + return nil, false +} + +func (t tableIndexProxy) IsTruthy() bool { + return true +} diff --git a/internal/dynamo-browse/services/scriptmanager/typemapping.go b/internal/dynamo-browse/services/scriptmanager/typemapping.go new file mode 100644 index 0000000..d7b8a47 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/typemapping.go @@ -0,0 +1,135 @@ +package scriptmanager + +import ( + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/dynamo-browse/internal/common/maputils" + "github.com/lmika/dynamo-browse/internal/common/sliceutils" + "github.com/pkg/errors" + "github.com/risor-io/risor/object" + "regexp" + "strconv" +) + +func tamarinValueToAttributeValue(val object.Object) (types.AttributeValue, error) { + switch v := val.(type) { + case *object.String: + return &types.AttributeValueMemberS{Value: v.Value()}, nil + case *object.Int: + return &types.AttributeValueMemberN{Value: strconv.FormatInt(v.Value(), 10)}, nil + case *object.Float: + return &types.AttributeValueMemberN{Value: strconv.FormatFloat(v.Value(), 'f', -1, 64)}, nil + case *object.Bool: + return &types.AttributeValueMemberBOOL{Value: v.Value()}, nil + case *object.NilType: + return &types.AttributeValueMemberNULL{Value: true}, nil + case *object.List: + attrValue, err := sliceutils.MapWithError(v.Value(), tamarinValueToAttributeValue) + if err != nil { + return nil, err + } + return &types.AttributeValueMemberL{Value: attrValue}, nil + case *object.Map: + attrValue, err := maputils.MapValuesWithError(v.Value(), tamarinValueToAttributeValue) + if err != nil { + return nil, err + } + return &types.AttributeValueMemberM{Value: attrValue}, nil + case *object.Set: + values := maputils.Values(v.Value()) + canBeNumSet := sliceutils.All(values, func(t object.Object) bool { + _, isInt := t.(*object.Int) + _, isFloat := t.(*object.Float) + return isInt || isFloat + }) + + if canBeNumSet { + return &types.AttributeValueMemberNS{ + Value: sliceutils.Map(values, func(t object.Object) string { + switch v := t.(type) { + case *object.Int: + return strconv.FormatInt(v.Value(), 10) + case *object.Float: + return strconv.FormatFloat(v.Value(), 'f', -1, 64) + } + panic(fmt.Sprintf("unhandled object type: %v", t.Type())) + }), + }, nil + } + return &types.AttributeValueMemberSS{ + Value: sliceutils.Map(values, func(t object.Object) string { + v, _ := object.AsString(t) + return v + }), + }, nil + } + return nil, errors.Errorf("type error: unsupported value type (got %v)", val.Type()) +} + +func attributeValueToTamarin(val types.AttributeValue) (object.Object, error) { + if val == nil { + return object.Nil, nil + } + + switch v := val.(type) { + case *types.AttributeValueMemberS: + return object.NewString(v.Value), nil + case *types.AttributeValueMemberN: + f, err := convertNumAttributeToTamarinValue(v.Value) + if err != nil { + return nil, errors.Errorf("value error: invalid N value: %v", v.Value) + } + return f, nil + case *types.AttributeValueMemberBOOL: + if v.Value { + return object.True, nil + } + return object.False, nil + case *types.AttributeValueMemberNULL: + return object.Nil, nil + case *types.AttributeValueMemberL: + list, err := sliceutils.MapWithError(v.Value, attributeValueToTamarin) + if err != nil { + return nil, err + } + return object.NewList(list), nil + case *types.AttributeValueMemberM: + objMap, err := maputils.MapValuesWithError(v.Value, attributeValueToTamarin) + if err != nil { + return nil, err + } + return object.NewMap(objMap), nil + case *types.AttributeValueMemberSS: + return object.NewSet(sliceutils.Map(v.Value, func(s string) object.Object { + return object.NewString(s) + })), nil + case *types.AttributeValueMemberNS: + nums, err := sliceutils.MapWithError(v.Value, func(s string) (object.Object, error) { + return convertNumAttributeToTamarinValue(s) + }) + if err != nil { + return nil, err + } + return object.NewSet(nums), nil + } + return nil, errors.Errorf("value error: cannot convert type %T to tamarin object", val) +} + +var intNumberPattern = regexp.MustCompile(`^[-]?[0-9]+$`) + +// XXX - this is pretty crappy in that it does not support large values +func convertNumAttributeToTamarinValue(n string) (object.Object, error) { + if intNumberPattern.MatchString(n) { + parsedInt, err := strconv.ParseInt(n, 10, 64) + if err != nil { + return nil, err + } + return object.NewInt(parsedInt), nil + } + + f, err := strconv.ParseFloat(n, 64) + if err != nil { + return nil, err + } + return object.NewFloat(f), nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/types.go b/internal/dynamo-browse/services/scriptmanager/types.go new file mode 100644 index 0000000..442f03b --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/types.go @@ -0,0 +1,35 @@ +package scriptmanager + +import ( + "context" +) + +type ScriptPlugin struct { + scriptService *Service + name string + definedCommands map[string]*Command + definedKeyBindings map[string]*Command + keyToKeyBinding map[string]string + relatedItems []*relatedItemBuilder +} + +func (sp *ScriptPlugin) Name() string { + return sp.name +} + +type Command struct { + plugin *ScriptPlugin + cmdFn func(ctx context.Context, args []string) error +} + +// Invoke will schedule the command for invocation. If the script scheduler is free, it will be started immediately. +// Otherwise an error will be returned. +func (c *Command) Invoke(ctx context.Context, args []string, errChan chan error) error { + return c.plugin.scriptService.sched.runNow(ctx, func(ctx context.Context) { + errChan <- c.cmdFn(ctx, args) + }) +} + +//func (c *Command) LookupRelevantItems(ctx context.Context, table *models.TableInfo, item *models.Item) error { +// +//} diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index fab7026..cb07519 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -1,11 +1,16 @@ package ui import ( + "log" + "os" + "strings" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/dynamo-browse/internal/common/ui/commandctrl" "github.com/lmika/dynamo-browse/internal/common/ui/events" "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" "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings" @@ -21,7 +26,7 @@ import ( "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils" bus "github.com/lmika/events" - "log" + "github.com/pkg/errors" ) const ( @@ -32,6 +37,8 @@ const ( ViewModeTableOnly = 4 ViewModeCount = 5 + + initRCFilename = "$HOME/.config/audax/dynamo-browse/init.rc" ) type Model struct { @@ -40,6 +47,7 @@ type Model struct { settingsController *controllers.SettingsController exportController *controllers.ExportController commandController *commandctrl.CommandController + scriptController *controllers.ScriptController jobController *controllers.JobsController colSelector *colselector.Model relSelector *relselector.Model @@ -67,6 +75,7 @@ func NewModel( jobController *controllers.JobsController, itemRendererService *itemrenderer.Service, cc *commandctrl.CommandController, + scriptController *controllers.ScriptController, eventBus *bus.Bus, keyBindingController *controllers.KeyBindingController, pasteboardProvider services.PasteboardProvider, @@ -85,13 +94,157 @@ func NewModel( dialogPrompt := dialogprompt.New(statusAndPrompt) tableSelect := tableselect.New(dialogPrompt, uiStyles) + cc.AddCommands(&commandctrl.CommandList{ + Commands: map[string]commandctrl.Command{ + "quit": commandctrl.NoArgCommand(tea.Quit), + "table": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + if len(args) == 0 { + return rc.ListTables(false) + } else { + return rc.ScanTable(args[0]) + } + }, + "export": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + if len(args) == 0 { + return events.Error(errors.New("expected filename")) + } + + 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 + if len(args) > 0 { + switch args[0] { + case "all": + markOp = controllers.MarkOpMark + case "none": + markOp = controllers.MarkOpUnmark + case "toggle": + markOp = controllers.MarkOpToggle + default: + return events.Error(errors.New("unrecognised mark operation")) + } + } + + var whereExpr = "" + if len(args) == 3 && args[1] == "-where" { + whereExpr = args[2] + } + + return rc.Mark(markOp, whereExpr) + }, + "next-page": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + return rc.NextPage() + }, + "delete": commandctrl.NoArgCommand(wc.DeleteMarked), + + // TEMP + "new-item": commandctrl.NoArgCommand(wc.NewItem), + "clone": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + return wc.CloneItem(dtv.SelectedItemIndex()) + }, + "set-attr": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + if len(args) == 0 { + return events.Error(errors.New("expected field")) + } + + var itemType = models.UnsetItemType + if len(args) == 2 { + switch strings.ToUpper(args[0]) { + case "-S": + itemType = models.StringItemType + case "-N": + itemType = models.NumberItemType + case "-BOOL": + itemType = models.BoolItemType + case "-NULL": + itemType = models.NullItemType + case "-TO": + itemType = models.ExprValueItemType + default: + return events.Error(errors.New("unrecognised item type")) + } + args = args[1:] + } + + return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, args[0]) + }, + "del-attr": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + if len(args) == 0 { + return events.Error(errors.New("expected field")) + } + return wc.DeleteAttribute(dtv.SelectedItemIndex(), args[0]) + }, + + "put": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + return wc.PutItems() + }, + "touch": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + return wc.TouchItem(dtv.SelectedItemIndex()) + }, + "noisy-touch": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + return wc.NoisyTouchItem(dtv.SelectedItemIndex()) + }, + + "echo": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + s := new(strings.Builder) + for _, arg := range args { + s.WriteString(arg) + } + return events.StatusMsg(s.String()) + }, + "set": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + switch len(args) { + case 1: + return settingsController.SetSetting(args[0], "") + case 2: + return settingsController.SetSetting(args[0], args[1]) + } + return events.Error(errors.New("expected: settingName [value]")) + }, + "rebind": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + if len(args) != 2 { + return events.Error(errors.New("expected: bindingName newKey")) + } + return keyBindingController.Rebind(args[0], args[1], ctx.FromFile) + }, + + "run-script": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + if len(args) != 1 { + return events.Error(errors.New("expected: script name")) + } + return scriptController.RunScript(args[0]) + }, + "load-script": func(ctx commandctrl.ExecContext, args []string) tea.Msg { + if len(args) != 1 { + return events.Error(errors.New("expected: script name")) + } + return scriptController.LoadScript(args[0]) + }, + + // Aliases + "unmark": cc.Alias("mark", []string{"none"}), + "sa": cc.Alias("set-attr", nil), + "da": cc.Alias("del-attr", nil), + "np": cc.Alias("next-page", nil), + "w": cc.Alias("put", nil), + "q": cc.Alias("quit", nil), + }, + }) + root := layout.FullScreen(tableSelect) return Model{ tableReadController: rc, tableWriteController: wc, commandController: cc, - exportController: exportController, + scriptController: scriptController, jobController: jobController, itemEdit: itemEdit, colSelector: colSelector, @@ -112,10 +265,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case controllers.SetTableItemView: cmd := m.setMainViewIndex(msg.ViewIndex) return m, cmd - case events.ResultSetUpdated: + case controllers.ResultSetUpdated: return m, tea.Batch( m.tableView.Refresh(), - events.SetStatus(msg.StatusMessage), + events.SetStatus(msg.StatusMessage()), ) case tea.KeyMsg: // TODO: use modes here @@ -138,7 +291,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keyMap.PromptForQuery): return m, m.tableReadController.PromptForQuery case key.Matches(msg, m.keyMap.PromptForFilter): - return m, m.tableReadController.PromptForFilter + return m, m.tableReadController.Filter case key.Matches(msg, m.keyMap.FetchNextPage): return m, m.tableReadController.NextPage case key.Matches(msg, m.keyMap.ViewBack): @@ -154,10 +307,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // return m, nil case key.Matches(msg, m.keyMap.ShowColumnOverlay): return m, events.SetTeaMessage(controllers.ShowColumnOverlay{}) - //case key.Matches(msg, m.keyMap.ShowRelItemsOverlay): - // if idx := m.tableView.SelectedItemIndex(); idx >= 0 { - // return m, events.SetTeaMessage(m.scriptController.LookupRelatedItems(idx)) - // } + case key.Matches(msg, m.keyMap.ShowRelItemsOverlay): + if idx := m.tableView.SelectedItemIndex(); idx >= 0 { + return m, events.SetTeaMessage(m.scriptController.LookupRelatedItems(idx)) + } case key.Matches(msg, m.keyMap.PromptForCommand): return m, m.commandController.Prompt case key.Matches(msg, m.keyMap.PromptForTable): @@ -180,6 +333,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.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 tea.Batch( m.tableReadController.Init, m.root.Init(), @@ -223,11 +382,3 @@ func (m *Model) promptToQuit() tea.Msg { return nil }) } - -func (m *Model) SelectedItemIndex() int { - return m.tableView.SelectedItemIndex() -} - -func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg { - return m.tableView.SetSelectedItemIndex(newIdx) -} diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 163de6f..cf61d72 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -208,27 +208,6 @@ func (m *Model) SelectedItemIndex() int { return selectedItem.itemIndex } -func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg { - cursor := m.table.Cursor() - switch { - case newIdx <= 0: - m.table.GoTop() - case newIdx >= len(m.rows)-1: - m.table.GoBottom() - case newIdx < cursor: - delta := cursor - newIdx - for d := 0; d < delta; d++ { - m.table.GoUp() - } - case newIdx > cursor: - delta := newIdx - cursor - for d := 0; d < delta; d++ { - m.table.GoDown() - } - } - return m.postSelectedItemChanged() -} - func (m *Model) selectedItem() (itemTableRow, bool) { resultSet := m.resultSet