diff --git a/.gitignore b/.gitignore index b14c548..2b59837 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ debug.log +.DS_store +.idea diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index 8adcce4..3c5c4bf 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -4,9 +4,11 @@ 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" @@ -26,7 +28,6 @@ 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" @@ -44,6 +45,7 @@ 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() @@ -104,7 +106,6 @@ 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) @@ -119,7 +120,6 @@ func main() { inputHistoryService, eventBus, pasteboardProvider, - scriptManagerService, *flagTable, ) tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) @@ -127,7 +127,6 @@ 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 == "" { @@ -157,10 +156,23 @@ func main() { } keyBindingService := keybindings_service.NewService(keyBindings) - keyBindingController := controllers.NewKeyBindingController(keyBindingService, scriptController) + keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil) - commandController := commandctrl.NewCommandController(inputHistoryService) - commandController.AddCommandLookupExtension(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.SetCommandCompletionProvider(columnsController) model := ui.NewModel( @@ -172,12 +184,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() @@ -185,8 +197,12 @@ func main() { p := tea.NewProgram(model, tea.WithAltScreen()) jobsController.SetMessageSender(p.Send) - scriptController.Init() - scriptController.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) log.Println("launching") if err := p.Start(); err != nil { diff --git a/go.mod b/go.mod index 4912f4c..18738aa 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/lmika/dynamo-browse -go 1.22 +go 1.24 -toolchain go1.22.0 +toolchain go1.24.0 require ( - github.com/alecthomas/participle/v2 v2.0.0-beta.5 + github.com/alecthomas/participle/v2 v2.1.1 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-20211210041137-0dc91e939890 + github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f 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/risor-io/risor v1.4.0 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 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/text v0.2.0 // indirect + github.com/kr/pretty v0.3.1 // 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 cc4b77a..462dd4c 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.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/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/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-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI= -github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ= +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/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,28 +139,23 @@ 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/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/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/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= @@ -250,6 +245,7 @@ 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 new file mode 100644 index 0000000..398a86b --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modopt.go @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..2a27d6e --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modpb.go @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..148723c --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -0,0 +1,308 @@ +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 new file mode 100644 index 0000000..878ad3b --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go @@ -0,0 +1,154 @@ +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 new file mode 100644 index 0000000..26253e6 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/modui.go @@ -0,0 +1,210 @@ +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 new file mode 100644 index 0000000..8dad249 --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -0,0 +1,216 @@ +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 new file mode 100644 index 0000000..f07965a --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/pvars.go @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..748aaea --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds.go @@ -0,0 +1,440 @@ +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 new file mode 100644 index 0000000..63f099f --- /dev/null +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go @@ -0,0 +1,218 @@ +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 c0d857f..9075da8 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -1,15 +1,17 @@ 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" @@ -17,19 +19,58 @@ 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) *CommandController { - return &CommandController{ +func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*CommandController, error) { + cc := &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) { @@ -37,6 +78,16 @@ 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) } @@ -83,29 +134,65 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms return nil } - tokens := shellwords.Split(input) - command := c.lookupCommand(tokens[0]) - if command == nil { - return events.Error(errors.New("no such command: " + tokens[0])) + select { + case c.cmdChan <- cmdMessage{cmd: input}: + // good + default: + return events.Error(errors.New("command currently running")) } - return command(ctx, tokens[1:]) + return nil } -func (c *CommandController) Alias(commandName string, aliasArgs []string) Command { - return func(ctx ExecContext, args []string) tea.Msg { +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 { command := c.lookupCommand(commandName) if command == nil { return events.Error(errors.New("no such command: " + commandName)) } - var allArgs []string - if len(aliasArgs) > 0 { - allArgs = append(append([]string{}, aliasArgs...), args...) - } else { - allArgs = args - } - return command(ctx, allArgs) + return command(ctx, args) } } @@ -124,39 +211,114 @@ func (c *CommandController) lookupCommand(name string) Command { return nil } -func (c *CommandController) ExecuteFile(filename string) error { - baseFilename := filepath.Base(filename) +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, ".") - if rcFile, err := os.ReadFile(filename); err == nil { - if err := c.executeFile(rcFile, baseFilename); err != nil { - return errors.Wrapf(err, "error executing %v", filename) + if stat, err := os.Stat(baseDir); err != nil { + if os.IsNotExist(err) { + continue + } + return err + } else if !stat.IsDir() { + 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 } - } 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)) +func (c *CommandController) ExecuteFile(ctx context.Context, filename string) error { + oldInteractive := c.interactive + c.interactive = false + defer func() { + c.interactive = oldInteractive + }() - lineNo := 0 - for scnr.Scan() { - lineNo++ - line := strings.TrimSpace(scnr.Text()) - if line == "" { - continue - } else if line[0] == '#' { - continue - } + baseFilename := filepath.Base(filename) - 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)) + 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 scnr.Err() + 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 } diff --git a/internal/common/ui/commandctrl/commandctrl_test.go b/internal/common/ui/commandctrl/commandctrl_test.go index b21783e..bcc112a 100644 --- a/internal/common/ui/commandctrl/commandctrl_test.go +++ b/internal/common/ui/commandctrl/commandctrl_test.go @@ -12,7 +12,8 @@ import ( func TestCommandController_Prompt(t *testing.T) { t.Run("prompt user for a command", func(t *testing.T) { - cmd := commandctrl.NewCommandController(mockIterProvider{}) + cmd, err := commandctrl.NewCommandController(mockIterProvider{}) + assert.NoError(t, err) res := cmd.Prompt() diff --git a/internal/common/ui/commandctrl/ctx.go b/internal/common/ui/commandctrl/ctx.go new file mode 100644 index 0000000..dffff70 --- /dev/null +++ b/internal/common/ui/commandctrl/ctx.go @@ -0,0 +1,63 @@ +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 1cb834a..e708d24 100644 --- a/internal/common/ui/commandctrl/iface.go +++ b/internal/common/ui/commandctrl/iface.go @@ -2,9 +2,15 @@ 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 new file mode 100644 index 0000000..16df613 --- /dev/null +++ b/internal/common/ui/commandctrl/packs.go @@ -0,0 +1,12 @@ +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 7861e09..cd922ff 100644 --- a/internal/common/ui/commandctrl/types.go +++ b/internal/common/ui/commandctrl/types.go @@ -1,11 +1,14 @@ package commandctrl -import tea "github.com/charmbracelet/bubbletea" +import ( + tea "github.com/charmbracelet/bubbletea" + "ucl.lmika.dev/ucl" +) -type Command func(ctx ExecContext, args []string) tea.Msg +type Command func(ctx ExecContext, args ucl.CallArgs) tea.Msg func NoArgCommand(cmd tea.Cmd) Command { - return func(ctx ExecContext, args []string) tea.Msg { + return func(ctx ExecContext, args ucl.CallArgs) tea.Msg { return cmd() } } diff --git a/internal/common/ui/events/resultset.go b/internal/common/ui/events/resultset.go new file mode 100644 index 0000000..cce739a --- /dev/null +++ b/internal/common/ui/events/resultset.go @@ -0,0 +1,5 @@ +package events + +type ResultSetUpdated struct { + StatusMessage string +} diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go index 7af1c41..ac0e74a 100644 --- a/internal/dynamo-browse/controllers/events.go +++ b/internal/dynamo-browse/controllers/events.go @@ -81,14 +81,6 @@ 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 7f4ce8e..23c91a9 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 nil + return events.StatusMsg("Table copied to clipboard") } // 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 043248f..3b9dc76 100644 --- a/internal/dynamo-browse/controllers/keybinding.go +++ b/internal/dynamo-browse/controllers/keybinding.go @@ -20,6 +20,10 @@ 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 deleted file mode 100644 index f706ddd..0000000 --- a/internal/dynamo-browse/controllers/scripts.go +++ /dev/null @@ -1,281 +0,0 @@ -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 deleted file mode 100644 index ffcb8a8..0000000 --- a/internal/dynamo-browse/controllers/scripts_test.go +++ /dev/null @@ -1,192 +0,0 @@ -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 6a886d2..8f7ea82 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -29,6 +29,14 @@ 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 6ef6e18..64aca40 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -61,7 +61,6 @@ type TableReadController struct { tableName string loadFromLastView bool pasteboardProvider services.PasteboardProvider - relatedItemSupplier RelatedItemSupplier // state mutex *sync.Mutex @@ -77,7 +76,6 @@ func NewTableReadController( inputHistoryService *inputhistory.Service, eventBus *bus.Bus, pasteboardProvider services.PasteboardProvider, - relatedItemSupplier RelatedItemSupplier, tableName string, ) *TableReadController { return &TableReadController{ @@ -90,7 +88,6 @@ func NewTableReadController( eventBus: eventBus, tableName: tableName, pasteboardProvider: pasteboardProvider, - relatedItemSupplier: relatedItemSupplier, mutex: new(sync.Mutex), } } @@ -182,6 +179,10 @@ 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, @@ -291,6 +292,12 @@ 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 @@ -333,27 +340,31 @@ func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg { }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} } -func (c *TableReadController) Filter() tea.Msg { +func (c *TableReadController) PromptForFilter() tea.Msg { return events.PromptForInputMsg{ Prompt: "filter: ", History: c.inputHistoryService.Iter(context.Background(), filterInputHistoryCategory), OnDone: func(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() + return c.Filter(value) }, } } +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 033a0cb..8f248c5 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 ResultSetUpdated{} + return events.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 ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -181,7 +181,7 @@ func (twc *TableWriteController) setToExpressionValue(idx int, attr *queryexpr.Q }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -205,7 +205,7 @@ func (twc *TableWriteController) setNumberValue(idx int, attr *queryexpr.QueryEx }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -234,7 +234,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr *queryexpr.QueryExpr }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } @@ -255,7 +255,7 @@ func (twc *TableWriteController) setNullValue(idx int, attr *queryexpr.QueryExpr }); err != nil { return events.Error(err) } - return ResultSetUpdated{} + return events.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 ResultSetUpdated{} + return events.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 ResultSetUpdated{ - statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"), + return events.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 ResultSetUpdated{} + return events.ResultSetUpdated{} }, } } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index efd0c14..0ace3d9 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -15,7 +15,6 @@ 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" @@ -587,7 +586,6 @@ type services struct { settingsController *controllers.SettingsController columnsController *controllers.ColumnsController exportController *controllers.ExportController - scriptController *controllers.ScriptController commandController *commandctrl.CommandController } @@ -607,7 +605,6 @@ 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) @@ -627,17 +624,14 @@ 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.AddCommandLookupExtension(scriptController) + commandController, _ := commandctrl.NewCommandController(inputHistoryService) if cfg.isReadOnly { if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil { @@ -651,12 +645,7 @@ 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, @@ -666,7 +655,6 @@ 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 03d0421..ff4e398 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -151,3 +151,37 @@ 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 2e55c7a..011931e 100644 --- a/internal/dynamo-browse/models/queryexpr/types.go +++ b/internal/dynamo-browse/models/queryexpr/types.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" "math/big" "strconv" + "strings" ) type exprValue interface { @@ -62,6 +63,14 @@ func newExprValueFromAttributeValue(ev types.AttributeValue) (exprValue, error) case *types.AttributeValueMemberS: return stringExprValue(xVal.Value), nil case *types.AttributeValueMemberN: + if !strings.Contains(xVal.Value, ".") { + iVal, err := strconv.ParseInt(xVal.Value, 10, 64) + if err != nil { + return nil, err + } + return int64ExprValue(iVal), nil + } + xNumVal, _, err := big.ParseFloat(xVal.Value, 10, 63, big.ToNearestEven) if err != nil { return nil, err @@ -139,6 +148,32 @@ func (s int64ExprValue) typeName() string { return "N" } +type bigIntExprValue struct { + num *big.Int +} + +func (i bigIntExprValue) asGoValue() any { + return i.num +} + +func (i bigIntExprValue) asAttributeValue() types.AttributeValue { + return &types.AttributeValueMemberN{Value: i.num.String()} +} + +func (i bigIntExprValue) asInt() int64 { + return i.num.Int64() +} + +func (i bigIntExprValue) asBigFloat() *big.Float { + var f big.Float + f.SetInt64(i.num.Int64()) + return &f +} + +func (s bigIntExprValue) typeName() string { + return "N" +} + type bigNumExprValue struct { num *big.Float } diff --git a/internal/dynamo-browse/services/scriptmanager/builtins.go b/internal/dynamo-browse/services/scriptmanager/builtins.go deleted file mode 100644 index 93c6e78..0000000 --- a/internal/dynamo-browse/services/scriptmanager/builtins.go +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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 deleted file mode 100644 index 39b4d9e..0000000 --- a/internal/dynamo-browse/services/scriptmanager/iface.go +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index bdfa6b1..0000000 --- a/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go +++ /dev/null @@ -1,216 +0,0 @@ -// 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 deleted file mode 100644 index b029dd6..0000000 --- a/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go +++ /dev/null @@ -1,116 +0,0 @@ -// 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 deleted file mode 100644 index 4ad97d4..0000000 --- a/internal/dynamo-browse/services/scriptmanager/modext.go +++ /dev/null @@ -1,270 +0,0 @@ -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 deleted file mode 100644 index b3413e6..0000000 --- a/internal/dynamo-browse/services/scriptmanager/modext_test.go +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index 455125b..0000000 --- a/internal/dynamo-browse/services/scriptmanager/modos_test.go +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 95c16c7..0000000 --- a/internal/dynamo-browse/services/scriptmanager/modsession.go +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index 8712cf5..0000000 --- a/internal/dynamo-browse/services/scriptmanager/modsession_test.go +++ /dev/null @@ -1,426 +0,0 @@ -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 deleted file mode 100644 index d53b2e4..0000000 --- a/internal/dynamo-browse/services/scriptmanager/modui.go +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 3a8b96d..0000000 --- a/internal/dynamo-browse/services/scriptmanager/modui_test.go +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 39d961f..0000000 --- a/internal/dynamo-browse/services/scriptmanager/opts.go +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 63d7629..0000000 --- a/internal/dynamo-browse/services/scriptmanager/relitem.go +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 953a3e4..0000000 --- a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go +++ /dev/null @@ -1,337 +0,0 @@ -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 deleted file mode 100644 index 3b7f354..0000000 --- a/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go +++ /dev/null @@ -1,355 +0,0 @@ -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 deleted file mode 100644 index 3846676..0000000 --- a/internal/dynamo-browse/services/scriptmanager/scrsched.go +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index eac6638..0000000 --- a/internal/dynamo-browse/services/scriptmanager/service.go +++ /dev/null @@ -1,250 +0,0 @@ -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 deleted file mode 100644 index 745124d..0000000 --- a/internal/dynamo-browse/services/scriptmanager/service_test.go +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 6841531..0000000 --- a/internal/dynamo-browse/services/scriptmanager/serviceopts.go +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 252348f..0000000 --- a/internal/dynamo-browse/services/scriptmanager/tableproxy.go +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index d7b8a47..0000000 --- a/internal/dynamo-browse/services/scriptmanager/typemapping.go +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 442f03b..0000000 --- a/internal/dynamo-browse/services/scriptmanager/types.go +++ /dev/null @@ -1,35 +0,0 @@ -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 cb07519..fab7026 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -1,16 +1,11 @@ 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" @@ -26,7 +21,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" - "github.com/pkg/errors" + "log" ) const ( @@ -37,8 +32,6 @@ const ( ViewModeTableOnly = 4 ViewModeCount = 5 - - initRCFilename = "$HOME/.config/audax/dynamo-browse/init.rc" ) type Model struct { @@ -47,7 +40,6 @@ type Model struct { settingsController *controllers.SettingsController exportController *controllers.ExportController commandController *commandctrl.CommandController - scriptController *controllers.ScriptController jobController *controllers.JobsController colSelector *colselector.Model relSelector *relselector.Model @@ -75,7 +67,6 @@ func NewModel( jobController *controllers.JobsController, itemRendererService *itemrenderer.Service, cc *commandctrl.CommandController, - scriptController *controllers.ScriptController, eventBus *bus.Bus, keyBindingController *controllers.KeyBindingController, pasteboardProvider services.PasteboardProvider, @@ -94,157 +85,13 @@ 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, - scriptController: scriptController, + exportController: exportController, jobController: jobController, itemEdit: itemEdit, colSelector: colSelector, @@ -265,10 +112,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case controllers.SetTableItemView: cmd := m.setMainViewIndex(msg.ViewIndex) return m, cmd - case controllers.ResultSetUpdated: + case events.ResultSetUpdated: return m, tea.Batch( m.tableView.Refresh(), - events.SetStatus(msg.StatusMessage()), + events.SetStatus(msg.StatusMessage), ) case tea.KeyMsg: // TODO: use modes here @@ -291,7 +138,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.Filter + return m, m.tableReadController.PromptForFilter case key.Matches(msg, m.keyMap.FetchNextPage): return m, m.tableReadController.NextPage case key.Matches(msg, m.keyMap.ViewBack): @@ -307,10 +154,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): @@ -333,12 +180,6 @@ 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(), @@ -382,3 +223,11 @@ 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 cf61d72..163de6f 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -208,6 +208,27 @@ 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