From c89b09447c047a7f3535043934cdd9fcd98a6d39 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 10 Jan 2023 22:27:13 +1100 Subject: [PATCH] Initial version of scripting (#40) * scripting: added service and controller for scripting * scripting: have got prompts working Scripts are now running in a separate go-routine. When a prompt is encountered, the script is paused and the user is prompted for input. This means that the script no longer needs to worry about synchronisation issues. * scripting: started working on the session methods * scripting: added methods to get items and attributes * scripting: have got loading of scripts working These act more like plugins and allow defining new commands. * scripting: have got script scheduling working Scripts are now executed on a dedicated goroutine and only one script can run at any one time. * scripting: added session.set_result_set(rs) * scripting: upgraded tamarin to 0.14 * scripting: started working on set_value * tamarin: replaced ad-hoc path with query expressions * scripting: changed value() and set_value() to attr() and set_attr() Also added 'delete_attr()' * scripting: added os.exec() This method is controlled by permissions which govern whether shellouts are allowed Also fixed a resizing bug with the status window which was not properly handling status messages with newlines * scripting: added the session.current_item() method * scripting: added placeholders to query expressions * scripting: added support for setting and deleteing items with placeholders Also refactored the dot AST type so that it support placeholders. Placeholders are not yet supported for subrefs yet, they need to be identifiers. * scripting: made setting the result-set push the current result-set to the backstack * scripting: started working on byte encoding of attribute values * scripting: finished attrcodec * scripting: integrated codec into expression * scripting: added equals and hashcode to queryexpr This finally finishes the work required to store queries in the backstack * scripting: fixed some bugs with the back-stack * scripting: upgraded Tamarin * scripting: removed some commented out code --- cmd/dynamo-browse/main.go | 10 +- go.mod | 32 +- go.sum | 86 ++++ internal/common/ui/commandctrl/commandctrl.go | 16 +- internal/common/ui/commandctrl/types.go | 4 + internal/common/ui/events/errors.go | 5 +- .../dynamo-browse/controllers/attrpath.go | 96 ----- internal/dynamo-browse/controllers/iface.go | 3 + internal/dynamo-browse/controllers/scripts.go | 194 +++++++++ .../dynamo-browse/controllers/scripts_test.go | 159 ++++++++ .../dynamo-browse/controllers/settings.go | 17 +- .../dynamo-browse/controllers/tableread.go | 55 ++- .../dynamo-browse/controllers/tablewrite.go | 59 ++- .../controllers/tablewrite_test.go | 89 +++- internal/dynamo-browse/controllers/uistate.go | 5 + .../models/attrcodec/codec_test.go | 111 +++++ .../dynamo-browse/models/attrcodec/decoder.go | 165 ++++++++ .../dynamo-browse/models/attrcodec/encoder.go | 139 +++++++ .../dynamo-browse/models/attrcodec/frames.go | 43 ++ .../dynamo-browse/models/attrutils/equals.go | 53 +++ .../dynamo-browse/models/attrutils/hash.go | 68 ++++ internal/dynamo-browse/models/models.go | 2 + .../dynamo-browse/models/queryexpr/ast.go | 52 ++- .../dynamo-browse/models/queryexpr/atom.go | 56 ++- .../dynamo-browse/models/queryexpr/boolnot.go | 29 +- .../dynamo-browse/models/queryexpr/comp.go | 34 +- .../dynamo-browse/models/queryexpr/conj.go | 40 +- .../dynamo-browse/models/queryexpr/disj.go | 36 +- .../dynamo-browse/models/queryexpr/dot.go | 48 +-- .../models/queryexpr/equality.go | 33 +- .../dynamo-browse/models/queryexpr/errors.go | 15 + .../dynamo-browse/models/queryexpr/expr.go | 212 +++++++++- .../models/queryexpr/expr_test.go | 380 +++++++++++++++++- .../dynamo-browse/models/queryexpr/fncall.go | 36 +- internal/dynamo-browse/models/queryexpr/in.go | 38 +- internal/dynamo-browse/models/queryexpr/is.go | 33 +- .../models/queryexpr/placeholder.go | 109 +++++ .../dynamo-browse/models/queryexpr/subref.go | 118 ++++++ .../dynamo-browse/models/queryexpr/values.go | 2 +- .../models/serialisable/viewsnapshot.go | 30 +- .../providers/settingstore/settingstore.go | 61 ++- .../services/scriptmanager/iface.go | 36 ++ .../scriptmanager/mocks/SessionService.go | 193 +++++++++ .../services/scriptmanager/mocks/UIService.go | 106 +++++ .../services/scriptmanager/modext.go | 67 +++ .../services/scriptmanager/modos.go | 47 +++ .../services/scriptmanager/modos_test.go | 110 +++++ .../services/scriptmanager/modsession.go | 121 ++++++ .../services/scriptmanager/modsession_test.go | 292 ++++++++++++++ .../services/scriptmanager/modui.go | 60 +++ .../services/scriptmanager/modui_test.go | 98 +++++ .../services/scriptmanager/opts.go | 45 +++ .../services/scriptmanager/resultsetproxy.go | 240 +++++++++++ .../scriptmanager/resultsetproxy_test.go | 135 +++++++ .../services/scriptmanager/scrsched.go | 52 +++ .../services/scriptmanager/service.go | 185 +++++++++ .../services/scriptmanager/service_test.go | 150 +++++++ .../services/scriptmanager/serviceopts.go | 11 + .../services/scriptmanager/types.go | 26 ++ .../services/viewsnapshot/service.go | 2 +- .../services/viewsnapshot/service_test.go | 47 ++- internal/dynamo-browse/ui/model.go | 21 +- .../ui/teamodels/dynamotableview/model.go | 7 +- .../ui/teamodels/layout/events.go | 3 + .../ui/teamodels/layout/fullscreen.go | 4 + .../ui/teamodels/statusandprompt/model.go | 38 +- 66 files changed, 4588 insertions(+), 281 deletions(-) delete mode 100644 internal/dynamo-browse/controllers/attrpath.go create mode 100644 internal/dynamo-browse/controllers/scripts.go create mode 100644 internal/dynamo-browse/controllers/scripts_test.go create mode 100644 internal/dynamo-browse/controllers/uistate.go create mode 100644 internal/dynamo-browse/models/attrcodec/codec_test.go create mode 100644 internal/dynamo-browse/models/attrcodec/decoder.go create mode 100644 internal/dynamo-browse/models/attrcodec/encoder.go create mode 100644 internal/dynamo-browse/models/attrcodec/frames.go create mode 100644 internal/dynamo-browse/models/attrutils/equals.go create mode 100644 internal/dynamo-browse/models/attrutils/hash.go create mode 100644 internal/dynamo-browse/models/queryexpr/placeholder.go create mode 100644 internal/dynamo-browse/models/queryexpr/subref.go create mode 100644 internal/dynamo-browse/services/scriptmanager/iface.go create mode 100644 internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go create mode 100644 internal/dynamo-browse/services/scriptmanager/mocks/UIService.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modext.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modos.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modos_test.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modsession.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modsession_test.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modui.go create mode 100644 internal/dynamo-browse/services/scriptmanager/modui_test.go create mode 100644 internal/dynamo-browse/services/scriptmanager/opts.go create mode 100644 internal/dynamo-browse/services/scriptmanager/resultsetproxy.go create mode 100644 internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go create mode 100644 internal/dynamo-browse/services/scriptmanager/scrsched.go create mode 100644 internal/dynamo-browse/services/scriptmanager/service.go create mode 100644 internal/dynamo-browse/services/scriptmanager/service_test.go create mode 100644 internal/dynamo-browse/services/scriptmanager/serviceopts.go create mode 100644 internal/dynamo-browse/services/scriptmanager/types.go create mode 100644 internal/dynamo-browse/ui/teamodels/layout/events.go diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index b000525..4fafd0d 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -18,6 +18,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/audax/internal/dynamo-browse/services/jobs" keybindings_service "github.com/lmika/audax/internal/dynamo-browse/services/keybindings" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" "github.com/lmika/audax/internal/dynamo-browse/services/tables" "github.com/lmika/audax/internal/dynamo-browse/services/viewsnapshot" "github.com/lmika/audax/internal/dynamo-browse/ui" @@ -95,6 +96,7 @@ func main() { tableService := tables.NewService(dynamoProvider, settingStore) workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo) + scriptManagerService := scriptmanager.New() jobsService := jobs.NewService(eventBus) state := controllers.NewState() @@ -103,13 +105,15 @@ func main() { tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore) columnsController := controllers.NewColumnsController(eventBus) exportController := controllers.NewExportController(state, columnsController) - settingsController := controllers.NewSettingsController(settingStore) + settingsController := controllers.NewSettingsController(settingStore, eventBus) keyBindings := keybindings.Default() + scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, settingsController, eventBus) keyBindingService := keybindings_service.NewService(keyBindings) keyBindingController := controllers.NewKeyBindingController(keyBindingService) commandController := commandctrl.NewCommandController() + commandController.AddCommandLookupExtension(scriptController) model := ui.NewModel( tableReadController, @@ -120,6 +124,8 @@ func main() { jobsController, itemRendererService, commandController, + scriptController, + eventBus, keyBindingController, keyBindings, ) @@ -130,6 +136,8 @@ func main() { p := tea.NewProgram(model, tea.WithAltScreen()) jobsController.SetMessageSender(p.Send) + scriptController.Init() + scriptController.SetMessageSender(p.Send) log.Println("launching") if err := p.Start(); err != nil { diff --git a/go.mod b/go.mod index 0670753..1578910 100644 --- a/go.mod +++ b/go.mod @@ -23,12 +23,16 @@ require ( github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe + github.com/mattn/go-runewidth v0.0.14 + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 + github.com/muesli/reflow v0.3.0 github.com/pkg/errors v0.9.1 - github.com/stretchr/testify v1.7.1 + github.com/stretchr/testify v1.8.1 golang.design/x/clipboard v0.6.2 ) require ( + atomicgo.dev/keyboard v0.2.8 // indirect github.com/DataDog/zstd v1.5.2 // indirect github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879 // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -44,32 +48,44 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 // indirect github.com/aws/smithy-go v1.11.3 // indirect github.com/aymanbagabas/go-osc52 v1.0.3 // indirect + github.com/cloudcmds/tamarin v1.0.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/gofrs/uuid v4.3.1+incompatible // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect + github.com/jackc/pgx/v5 v5.0.4 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect - github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect 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/sahilm/fuzzy v0.1.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/tidwall/gjson v1.14.3 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/wI2L/jsondiff v0.3.0 // indirect go.etcd.io/bbolt v1.3.6 // indirect - golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect + golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect + golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a // indirect golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect golang.org/x/mobile v0.0.0-20210716004757-34ab1303b554 // indirect - golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect + golang.org/x/sys v0.1.0 // indirect golang.org/x/term v0.0.0-20220919170432-7a66f970e087 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/text v0.3.8 // indirect google.golang.org/appengine v1.6.7 // indirect - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3c67b37..8921650 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,15 @@ +atomicgo.dev/keyboard v0.2.8 h1:Di09BitwZgdTV1hPyX/b9Cqxi8HVuJQwWivnZUEqlj4= +atomicgo.dev/keyboard v0.2.8/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= 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/participle v0.7.1 h1:2bN7reTw//5f0cugJcTOnY/NYZcWQOaajW+BwZB5xWs= @@ -13,6 +22,7 @@ github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1p github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= 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/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w= @@ -80,11 +90,21 @@ github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DA github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= +github.com/cloudcmds/tamarin v0.0.12 h1:xigMcfala5I81fh+6FSaJpjiKyWTOqzdf/GIQnsk/oc= +github.com/cloudcmds/tamarin v0.0.12/go.mod h1:U1aHBoAFtJbI9jzgaj8TUo9C6vfzUKzn1OhWKIdigVM= +github.com/cloudcmds/tamarin v0.0.14 h1:LNHz/CplhiM9u4SVy/9dGjyXpMTvKMmWcuO0+f0t5Ls= +github.com/cloudcmds/tamarin v0.0.14/go.mod h1:U1aHBoAFtJbI9jzgaj8TUo9C6vfzUKzn1OhWKIdigVM= +github.com/cloudcmds/tamarin v1.0.0 h1:PhrJ74FCUJo24/nIPXnQe9E3WVEIYo4aG58pICOMDBE= +github.com/cloudcmds/tamarin v1.0.0/go.mod h1:U1aHBoAFtJbI9jzgaj8TUo9C6vfzUKzn1OhWKIdigVM= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= @@ -99,12 +119,28 @@ github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgx/v5 v5.0.4 h1:r5O6y84qHX/z/HZV40JBdx2obsHz7/uRj5b+CcYEdeY= +github.com/jackc/pgx/v5 v5.0.4/go.mod h1:U0ynklHtgg43fue9Ly30w3OCSTDPlXjig9ghrNGaguQ= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -124,7 +160,10 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= @@ -158,6 +197,13 @@ 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/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -165,12 +211,34 @@ github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= +github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/wI2L/jsondiff v0.3.0 h1:iTzQ9u/d86GE9RsBzVHX88f2EA1vQUboHwLhSQFc1s4= +github.com/wI2L/jsondiff v0.3.0/go.mod h1:y1IMzNNjlSsk3IUoJdRJO7VRBtzMvRgyo4Vu0LdHpTc= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= @@ -179,8 +247,12 @@ golang.design/x/clipboard v0.6.2/go.mod h1:kqBSweBP0/im4SZGGjLrppH0D400Hnfo5WbFK golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a h1:tlXy25amD5A7gOfbXdqCGN5k8ESEed/Ee1E5RcrYnqU= +golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= @@ -209,14 +281,21 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI= golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -228,6 +307,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -242,10 +323,15 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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 h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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= diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go index 994c748..fc6c627 100644 --- a/internal/common/ui/commandctrl/commandctrl.go +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -15,12 +15,14 @@ import ( ) type CommandController struct { - commandList *CommandList + commandList *CommandList + lookupExtensions []CommandLookupExtension } func NewCommandController() *CommandController { return &CommandController{ - commandList: nil, + commandList: nil, + lookupExtensions: nil, } } @@ -29,6 +31,10 @@ func (c *CommandController) AddCommands(ctx *CommandList) { c.commandList = ctx } +func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) { + c.lookupExtensions = append(c.lookupExtensions, ext) +} + func (c *CommandController) Prompt() tea.Msg { return events.PromptForInputMsg{ Prompt: ":", @@ -80,6 +86,12 @@ func (c *CommandController) lookupCommand(name string) Command { return cmd } } + + for _, exts := range c.lookupExtensions { + if cmd := exts.LookupCommand(name); cmd != nil { + return cmd + } + } return nil } diff --git a/internal/common/ui/commandctrl/types.go b/internal/common/ui/commandctrl/types.go index 79c4828..c8a9058 100644 --- a/internal/common/ui/commandctrl/types.go +++ b/internal/common/ui/commandctrl/types.go @@ -15,3 +15,7 @@ type CommandList struct { parent *CommandList } + +type CommandLookupExtension interface { + LookupCommand(name string) Command +} diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go index 3f0a2c9..e4077b4 100644 --- a/internal/common/ui/events/errors.go +++ b/internal/common/ui/events/errors.go @@ -20,6 +20,7 @@ type ModeMessage string // PromptForInput indicates that the context is requesting a line of input type PromptForInputMsg struct { - Prompt string - OnDone func(value string) tea.Msg + Prompt string + OnDone func(value string) tea.Msg + OnCancel func() tea.Msg } diff --git a/internal/dynamo-browse/controllers/attrpath.go b/internal/dynamo-browse/controllers/attrpath.go deleted file mode 100644 index e909c97..0000000 --- a/internal/dynamo-browse/controllers/attrpath.go +++ /dev/null @@ -1,96 +0,0 @@ -package controllers - -import ( - "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" - "github.com/lmika/audax/internal/dynamo-browse/models" - "github.com/pkg/errors" - "strings" -) - -type attrPath []string - -func newAttrPath(expr string) attrPath { - return strings.Split(expr, ".") -} - -func (ap attrPath) follow(item models.Item) (types.AttributeValue, error) { - var step types.AttributeValue - for i, seg := range ap { - if i == 0 { - step = item[seg] - continue - } - - switch s := step.(type) { - case *types.AttributeValueMemberM: - step = s.Value[seg] - default: - return nil, errors.Errorf("seg %v expected to be a map", i) - } - } - return step, nil -} - -func (ap attrPath) deleteAt(item models.Item) error { - if len(ap) == 1 { - delete(item, ap[0]) - return nil - } - - var step types.AttributeValue - for i, seg := range ap[:len(ap)-1] { - if i == 0 { - step = item[seg] - continue - } - - switch s := step.(type) { - case *types.AttributeValueMemberM: - step = s.Value[seg] - default: - return errors.Errorf("seg %v expected to be a map", i) - } - } - - lastSeg := ap[len(ap)-1] - switch s := step.(type) { - case *types.AttributeValueMemberM: - delete(s.Value, lastSeg) - default: - return errors.Errorf("last seg expected to be a map, but was %T", lastSeg) - } - - return nil -} - -func (ap attrPath) setAt(item models.Item, newValue types.AttributeValue) error { - if len(ap) == 1 { - item[ap[0]] = newValue - return nil - } - - var step types.AttributeValue - for i, seg := range ap[:len(ap)-1] { - if i == 0 { - step = item[seg] - continue - } - - switch s := step.(type) { - case *types.AttributeValueMemberM: - step = s.Value[seg] - default: - return errors.Errorf("seg %v expected to be a map", i) - } - } - - lastSeg := ap[len(ap)-1] - switch s := step.(type) { - case *types.AttributeValueMemberM: - s.Value[lastSeg] = newValue - default: - return errors.Errorf("last seg expected to be a map, but was %T", lastSeg) - } - - return nil -} diff --git a/internal/dynamo-browse/controllers/iface.go b/internal/dynamo-browse/controllers/iface.go index 0cb8ec2..2228605 100644 --- a/internal/dynamo-browse/controllers/iface.go +++ b/internal/dynamo-browse/controllers/iface.go @@ -3,6 +3,7 @@ package controllers import ( "context" "github.com/lmika/audax/internal/dynamo-browse/models" + "io/fs" ) type TableReadService interface { @@ -18,4 +19,6 @@ type SettingsProvider interface { SetReadOnly(ro bool) error DefaultLimit() (limit int) SetDefaultLimit(limit int) error + ScriptLookupFS() ([]fs.FS, error) + SetScriptLookupPaths(value string) error } diff --git a/internal/dynamo-browse/controllers/scripts.go b/internal/dynamo-browse/controllers/scripts.go new file mode 100644 index 0000000..09eca28 --- /dev/null +++ b/internal/dynamo-browse/controllers/scripts.go @@ -0,0 +1,194 @@ +package controllers + +import ( + "context" + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/common/ui/commandctrl" + "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" + bus "github.com/lmika/events" + "github.com/pkg/errors" + "log" + "strings" +) + +type ScriptController struct { + scriptManager *scriptmanager.Service + tableReadController *TableReadController + settingsController *SettingsController + eventBus *bus.Bus + sendMsg func(msg tea.Msg) +} + +func NewScriptController( + scriptManager *scriptmanager.Service, + tableReadController *TableReadController, + settingsController *SettingsController, + eventBus *bus.Bus, +) *ScriptController { + sc := &ScriptController{ + scriptManager: scriptManager, + tableReadController: tableReadController, + 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) + } + sc.scriptManager.SetDefaultOptions(scriptmanager.Options{ + OSExecShell: "/bin/bash", + Permissions: scriptmanager.Permissions{ + AllowShellCommands: true, + }, + }) +} + +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) { + currentResultSet := s.sc.tableReadController.state.ResultSet() + if currentResultSet == nil { + // TODO: this should only be used if there's no current table + return nil, errors.New("no table selected") + } + + 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) + } + + newResultSet, err := s.sc.tableReadController.tableService.ScanOrQuery(context.Background(), currentResultSet.TableInfo, expr) + if err != nil { + return nil, err + } + return newResultSet, nil +} diff --git a/internal/dynamo-browse/controllers/scripts_test.go b/internal/dynamo-browse/controllers/scripts_test.go new file mode 100644 index 0000000..64026a1 --- /dev/null +++ b/internal/dynamo-browse/controllers/scripts_test.go @@ -0,0 +1,159 @@ +package controllers_test + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/controllers" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +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"').unwrap() + 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("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"').unwrap() + 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"').unwrap() + 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) { + srv := newService(t, serviceConfig{ + tableName: "alpha-table", + scriptFS: testScriptFile(t, "test.tm", ` + ext.command("mycommand", func(name) { + ui.print("Hello, ", name) + }) + `), + }) + + invokeCommand(t, srv.scriptController.LoadScript("test.tm")) + invokeCommand(t, srv.commandController.Execute(`mycommand "test name"`)) + + srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second) + + assert.Len(t, srv.msgSender.msgs, 1) + assert.Equal(t, events.StatusMsg("Hello, test name"), 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/settings.go b/internal/dynamo-browse/controllers/settings.go index 21aedd2..6ff8fc0 100644 --- a/internal/dynamo-browse/controllers/settings.go +++ b/internal/dynamo-browse/controllers/settings.go @@ -4,18 +4,25 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/audax/internal/common/ui/events" + bus "github.com/lmika/events" "github.com/pkg/errors" "log" "strconv" ) +const ( + BusEventSettingsUpdated = "settings.updated" +) + type SettingsController struct { settings SettingsProvider + bus *bus.Bus } -func NewSettingsController(sp SettingsProvider) *SettingsController { +func NewSettingsController(sp SettingsProvider, bus *bus.Bus) *SettingsController { return &SettingsController{ settings: sp, + bus: bus, } } @@ -40,7 +47,7 @@ func (sc *SettingsController) SetSetting(name string, value string) tea.Msg { case "default-limit": newLimit, err := strconv.Atoi(value) if err != nil { - return errors.Wrapf(err, "bad value: %v", value) + return events.Error(errors.Wrapf(err, "bad value: %v", value)) } if err := sc.settings.SetDefaultLimit(newLimit); err != nil { @@ -50,6 +57,12 @@ func (sc *SettingsController) SetSetting(name string, value string) tea.Msg { Message: events.StatusMsg(fmt.Sprintf("Default query limit now %v", newLimit)), Next: SettingsUpdated{}, } + case "script.lookup-path": + if err := sc.settings.SetScriptLookupPaths(value); err != nil { + return events.Error(err) + } + sc.bus.Fire(BusEventSettingsUpdated, name, value) + return SettingsUpdated{} } return events.Error(errors.Errorf("unrecognised setting: %v", name)) diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 48c99ac..ac9875b 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -1,6 +1,7 @@ package controllers import ( + "bytes" "context" "fmt" tea "github.com/charmbracelet/bubbletea" @@ -27,6 +28,7 @@ const ( resultSetUpdateSnapshotRestore resultSetUpdateRescan resultSetUpdateTouch + resultSetUpdateScript ) type MarkOp int @@ -138,13 +140,22 @@ func (c *TableReadController) PromptForQuery() tea.Msg { return events.StatusMsg("Result-set is nil") } - return c.runQuery(resultSet.TableInfo, value, "", true) + var q *queryexpr.QueryExpr + if value != "" { + var err error + q, err = queryexpr.Parse(value) + if err != nil { + return events.Error(err) + } + } + + return c.runQuery(resultSet.TableInfo, q, "", true) }, } } -func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFilter string, pushSnapshot bool) tea.Msg { - if query == "" { +func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query *queryexpr.QueryExpr, newFilter string, pushSnapshot bool) tea.Msg { + if query == nil { return NewJob(c.jobController, "Scanning…", func(ctx context.Context) (*models.ResultSet, error) { newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, nil) @@ -156,14 +167,9 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi }).OnEither(c.handleResultSetFromJobResult(newFilter, pushSnapshot, resultSetUpdateQuery)).Submit() } - expr, err := queryexpr.Parse(query) - if err != nil { - return events.Error(err) - } - return c.doIfNoneDirty(func() tea.Msg { return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) { - newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, expr) + newResultSet, err := c.tableService.ScanOrQuery(context.Background(), tableInfo, query) if newFilter != "" && newResultSet != nil { newResultSet = c.tableService.Filter(newResultSet, newFilter) @@ -219,10 +225,17 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, TableName: resultSet.TableInfo.Name, Filter: filter, } + if q := resultSet.Query; q != nil { - details.Query = q.String() + if bs, err := q.SerializeToBytes(); err == nil { + details.Query = bs + details.QueryHash = q.HashCode() + } else { + log.Printf("cannot serialize query to bytes: %v", err) + } } + log.Printf("pushing to backstack: table = %v, filter = %v, query_hash = %v", details.TableName, details.Filter, details.QueryHash) if err := c.workspaceService.PushSnapshot(details); err != nil { log.Printf("cannot push snapshot: %v", err) } @@ -307,6 +320,8 @@ func (c *TableReadController) ViewBack() tea.Msg { return events.StatusMsg("Backstack is empty") } + log.Printf("view back: table = %v, filter = %v, query_hash = %v", + viewSnapshot.Details.TableName, viewSnapshot.Details.Filter, viewSnapshot.Details.QueryHash) return c.updateViewToSnapshot(viewSnapshot) } @@ -325,6 +340,14 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi var err error currentResultSet := c.state.ResultSet() + var query *queryexpr.QueryExpr + if len(viewSnapshot.Details.Query) > 0 { + query, err = queryexpr.DeserializeFrom(bytes.NewReader(viewSnapshot.Details.Query)) + if err != nil { + return err + } + } + if currentResultSet == nil { return NewJob(c.jobController, "Fetching table info…", func(ctx context.Context) (*models.TableInfo, error) { tableInfo, err := c.tableService.Describe(context.Background(), viewSnapshot.Details.TableName) @@ -333,16 +356,16 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi } return tableInfo, nil }).OnDone(func(tableInfo *models.TableInfo) tea.Msg { - return c.runQuery(tableInfo, viewSnapshot.Details.Query, viewSnapshot.Details.Filter, false) + return c.runQuery(tableInfo, query, viewSnapshot.Details.Filter, false) }).Submit() } - var currentQueryExpr string - if currentResultSet.Query != nil { - currentQueryExpr = currentResultSet.Query.String() + queryEqualsCurrentQuery := false + if q, ok := currentResultSet.Query.(*queryexpr.QueryExpr); ok && q != nil { + queryEqualsCurrentQuery = q.Equal(query) } - if viewSnapshot.Details.TableName == currentResultSet.TableInfo.Name && viewSnapshot.Details.Query == currentQueryExpr { + if viewSnapshot.Details.TableName == currentResultSet.TableInfo.Name && queryEqualsCurrentQuery { return NewJob(c.jobController, "Applying filter…", func(ctx context.Context) (*models.ResultSet, error) { return c.tableService.Filter(currentResultSet, viewSnapshot.Details.Filter), nil }).OnEither(c.handleResultSetFromJobResult(viewSnapshot.Details.Filter, false, resultSetUpdateSnapshotRestore)).Submit() @@ -357,7 +380,7 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi } } - return c.runQuery(tableInfo, viewSnapshot.Details.Query, viewSnapshot.Details.Filter, false), nil + return c.runQuery(tableInfo, query, viewSnapshot.Details.Filter, false), nil }).OnDone(func(m tea.Msg) tea.Msg { return m }).Submit() diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 8954daf..cc1410a 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -8,8 +8,10 @@ import ( "github.com/lmika/audax/internal/common/sliceutils" "github.com/lmika/audax/internal/common/ui/events" "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" "github.com/lmika/audax/internal/dynamo-browse/services/tables" "github.com/pkg/errors" + "log" "strconv" ) @@ -81,48 +83,57 @@ func (twc *TableWriteController) NewItem() tea.Msg { } func (twc *TableWriteController) SetAttributeValue(idx int, itemType models.ItemType, key string) tea.Msg { - apPath := newAttrPath(key) + path, err := queryexpr.Parse(key) + if err != nil { + return events.Error(err) + } var attrValue types.AttributeValue if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) (err error) { - attrValue, err = apPath.follow(set.Items()[idx]) + if !path.IsModifiablePath(set.Items()[idx]) { + return errors.Errorf("path cannot be used to set attribute value") + } + + attrValue, err = path.EvalItem(set.Items()[idx]) return err }); err != nil { return events.Error(err) } + log.Printf("sa attribute value = %v", attrValue) + switch itemType { case models.UnsetItemType: switch attrValue.(type) { case *types.AttributeValueMemberS: - return twc.setStringValue(idx, apPath) + return twc.setStringValue(idx, path) case *types.AttributeValueMemberN: - return twc.setNumberValue(idx, apPath) + return twc.setNumberValue(idx, path) case *types.AttributeValueMemberBOOL: - return twc.setBoolValue(idx, apPath) + return twc.setBoolValue(idx, path) default: return events.Error(errors.New("attribute type for key must be set")) } case models.StringItemType: - return twc.setStringValue(idx, apPath) + return twc.setStringValue(idx, path) case models.NumberItemType: - return twc.setNumberValue(idx, apPath) + return twc.setNumberValue(idx, path) case models.BoolItemType: - return twc.setBoolValue(idx, apPath) + return twc.setBoolValue(idx, path) case models.NullItemType: - return twc.setNullValue(idx, apPath) + return twc.setNullValue(idx, path) default: return events.Error(errors.New("unsupported attribute type")) } } -func (twc *TableWriteController) setStringValue(idx int, attr attrPath) tea.Msg { +func (twc *TableWriteController) setStringValue(idx int, attr *queryexpr.QueryExpr) tea.Msg { return events.PromptForInputMsg{ Prompt: "string value: ", OnDone: func(value string) tea.Msg { if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error { - if err := attr.setAt(item, &types.AttributeValueMemberS{Value: value}); err != nil { + if err := attr.SetEvalItem(item, &types.AttributeValueMemberS{Value: value}); err != nil { return err } set.SetDirty(idx, true) @@ -140,13 +151,13 @@ func (twc *TableWriteController) setStringValue(idx int, attr attrPath) tea.Msg } } -func (twc *TableWriteController) setNumberValue(idx int, attr attrPath) tea.Msg { +func (twc *TableWriteController) setNumberValue(idx int, attr *queryexpr.QueryExpr) tea.Msg { return events.PromptForInputMsg{ Prompt: "number value: ", OnDone: func(value string) tea.Msg { if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error { - if err := attr.setAt(item, &types.AttributeValueMemberN{Value: value}); err != nil { + if err := attr.SetEvalItem(item, &types.AttributeValueMemberN{Value: value}); err != nil { return err } set.SetDirty(idx, true) @@ -164,7 +175,7 @@ func (twc *TableWriteController) setNumberValue(idx int, attr attrPath) tea.Msg } } -func (twc *TableWriteController) setBoolValue(idx int, attr attrPath) tea.Msg { +func (twc *TableWriteController) setBoolValue(idx int, attr *queryexpr.QueryExpr) tea.Msg { return events.PromptForInputMsg{ Prompt: "bool value: ", OnDone: func(value string) tea.Msg { @@ -175,7 +186,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr attrPath) tea.Msg { if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error { - if err := attr.setAt(item, &types.AttributeValueMemberBOOL{Value: b}); err != nil { + if err := attr.SetEvalItem(item, &types.AttributeValueMemberBOOL{Value: b}); err != nil { return err } set.SetDirty(idx, true) @@ -193,10 +204,10 @@ func (twc *TableWriteController) setBoolValue(idx int, attr attrPath) tea.Msg { } } -func (twc *TableWriteController) setNullValue(idx int, attr attrPath) tea.Msg { +func (twc *TableWriteController) setNullValue(idx int, attr *queryexpr.QueryExpr) tea.Msg { if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { if err := applyToMarkedItems(set, idx, func(idx int, item models.Item) error { - if err := attr.setAt(item, &types.AttributeValueMemberNULL{Value: true}); err != nil { + if err := attr.SetEvalItem(item, &types.AttributeValueMemberNULL{Value: true}); err != nil { return err } set.SetDirty(idx, true) @@ -213,18 +224,22 @@ func (twc *TableWriteController) setNullValue(idx int, attr attrPath) tea.Msg { } func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg { - // Verify that the expression is valid - apPath := newAttrPath(key) + path, err := queryexpr.Parse(key) + if err != nil { + return events.Error(err) + } if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { - _, err := apPath.follow(set.Items()[idx]) - return err + if !path.IsModifiablePath(set.Items()[idx]) { + return errors.Errorf("path cannot be used to set attribute value") + } + return nil }); err != nil { return events.Error(err) } if err := twc.state.withResultSetReturningError(func(set *models.ResultSet) error { - err := apPath.deleteAt(set.Items()[idx]) + err := path.DeleteAttribute(set.Items()[idx]) if err != nil { return err } diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index 1c21e1a..58b76e6 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -3,6 +3,8 @@ package controllers_test import ( "fmt" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/audax/internal/common/ui/commandctrl" "github.com/lmika/audax/internal/dynamo-browse/controllers" "github.com/lmika/audax/internal/dynamo-browse/models" "github.com/lmika/audax/internal/dynamo-browse/providers/dynamo" @@ -10,13 +12,18 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore" "github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer" "github.com/lmika/audax/internal/dynamo-browse/services/jobs" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" "github.com/lmika/audax/internal/dynamo-browse/services/tables" "github.com/lmika/audax/internal/dynamo-browse/services/viewsnapshot" "github.com/lmika/audax/test/testdynamo" "github.com/lmika/audax/test/testworkspace" bus "github.com/lmika/events" "github.com/stretchr/testify/assert" + "io/fs" + "sync" "testing" + "testing/fstest" + "time" ) func TestTableWriteController_NewItem(t *testing.T) { @@ -569,6 +576,7 @@ func TestTableWriteController_DeleteMarked(t *testing.T) { } type services struct { + msgSender *msgSender state *controllers.State settingProvider controllers.SettingsProvider readController *controllers.TableReadController @@ -576,11 +584,14 @@ type services struct { settingsController *controllers.SettingsController columnsController *controllers.ColumnsController exportController *controllers.ExportController + scriptController *controllers.ScriptController + commandController *commandctrl.CommandController } type serviceConfig struct { tableName string isReadOnly bool + scriptFS fs.FS } func newService(t *testing.T, cfg serviceConfig) *services { @@ -590,6 +601,7 @@ func newService(t *testing.T, cfg serviceConfig) *services { settingStore := settingstore.New(ws) workspaceService := viewsnapshot.NewService(resultSetSnapshotStore) itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer()) + scriptService := scriptmanager.New() client := testdynamo.SetupTestTable(t, testData) @@ -601,9 +613,13 @@ func newService(t *testing.T, cfg serviceConfig) *services { jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true) readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, jobsController, eventBus, cfg.tableName) writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore) - settingsController := controllers.NewSettingsController(settingStore) + settingsController := controllers.NewSettingsController(settingStore, eventBus) columnsController := controllers.NewColumnsController(eventBus) exportController := controllers.NewExportController(state, columnsController) + scriptController := controllers.NewScriptController(scriptService, readController, settingsController, eventBus) + + commandController := commandctrl.NewCommandController() + commandController.AddCommandLookupExtension(scriptController) if cfg.isReadOnly { if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil { @@ -611,6 +627,13 @@ func newService(t *testing.T, cfg serviceConfig) *services { } } + msgSender := &msgSender{} + scriptController.Init() + 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, settingProvider: settingStore, @@ -619,5 +642,69 @@ func newService(t *testing.T, cfg serviceConfig) *services { settingsController: settingsController, columnsController: columnsController, exportController: exportController, + scriptController: scriptController, + commandController: commandController, + msgSender: msgSender, } } + +func testScriptFile(t *testing.T, filename, code string) fs.FS { + t.Helper() + + testFs := fstest.MapFS{ + filename: &fstest.MapFile{ + Data: []byte(code), + }, + } + return testFs +} + +type msgSender struct { + mutex sync.Mutex + msgs []tea.Msg + waitChan chan struct{} +} + +func (s *msgSender) send(msg tea.Msg) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.msgs = append(s.msgs, msg) + if s.waitChan != nil { + close(s.waitChan) + s.waitChan = nil + } +} + +func (s *msgSender) waitForAtLeastOneMessages(t *testing.T, d time.Duration) { + t.Helper() + + s.mutex.Lock() + msgLen := len(s.msgs) + s.mutex.Unlock() + + if msgLen > 0 { + return + } + + // Wait for a message + waitChan := s.afterNextMessage() + + select { + case <-waitChan: + case <-time.After(d): + t.Fatalf("timeout waiting for next message") + } +} + +func (s *msgSender) afterNextMessage() chan struct{} { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.waitChan != nil { + panic("More than one wait chan") + } + newWaitChan := make(chan struct{}) + s.waitChan = newWaitChan + return newWaitChan +} diff --git a/internal/dynamo-browse/controllers/uistate.go b/internal/dynamo-browse/controllers/uistate.go new file mode 100644 index 0000000..12245f3 --- /dev/null +++ b/internal/dynamo-browse/controllers/uistate.go @@ -0,0 +1,5 @@ +package controllers + +type UIStateProvider interface { + SelectedRowIndex() int +} diff --git a/internal/dynamo-browse/models/attrcodec/codec_test.go b/internal/dynamo-browse/models/attrcodec/codec_test.go new file mode 100644 index 0000000..866e42e --- /dev/null +++ b/internal/dynamo-browse/models/attrcodec/codec_test.go @@ -0,0 +1,111 @@ +package attrcodec_test + +import ( + "bytes" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models/attrcodec" + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestCodec(t *testing.T) { + t.Run("should be able to encode and decode", func(t *testing.T) { + scenarios := []struct { + name string + val types.AttributeValue + }{ + {name: "string", val: &types.AttributeValueMemberS{Value: "Hello world"}}, + {name: "empty string", val: &types.AttributeValueMemberS{Value: ""}}, + {name: "large string", val: &types.AttributeValueMemberS{Value: strings.Repeat("DynamoDB", 256)}}, + + {name: "number", val: &types.AttributeValueMemberN{Value: "12345"}}, + {name: "large number", val: &types.AttributeValueMemberN{Value: "123456789012345678901234567890"}}, + + {name: "true bool", val: &types.AttributeValueMemberBOOL{Value: true}}, + {name: "false bool", val: &types.AttributeValueMemberBOOL{Value: false}}, + + {name: "true null", val: &types.AttributeValueMemberNULL{Value: true}}, + {name: "false null", val: &types.AttributeValueMemberNULL{Value: false}}, + + {name: "bytes", val: &types.AttributeValueMemberB{Value: []byte{1, 2, 3, 4, 5}}}, + + {name: "simple list", val: &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: "apple"}, + &types.AttributeValueMemberS{Value: "banana"}, + &types.AttributeValueMemberS{Value: "cherry"}, + }}}, + {name: "nested lists", val: &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: "red apple"}, + &types.AttributeValueMemberS{Value: "green apple"}, + }}, + &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: "banana"}, + &types.AttributeValueMemberS{Value: "banana bread"}, + &types.AttributeValueMemberS{Value: "banana cake"}, + }}, + &types.AttributeValueMemberS{Value: "cherry"}, + &types.AttributeValueMemberS{Value: "can't make anything with cherries"}, + }}}, + + {name: "simple map", val: &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "alpha": &types.AttributeValueMemberS{Value: "I am an apple"}, + "bravo": &types.AttributeValueMemberN{Value: "123.45"}, + "charlie": &types.AttributeValueMemberS{Value: "things go here"}, + }}}, + {name: "nested maps", val: &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "alpha": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: "red apple"}, + &types.AttributeValueMemberS{Value: "green apple"}, + }}, + "bravo": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "good": &types.AttributeValueMemberS{Value: "stuff"}, + "is": &types.AttributeValueMemberS{Value: "written"}, + "in": &types.AttributeValueMemberS{Value: "the unit tests"}, + }}, + "coords": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "lat": &types.AttributeValueMemberN{Value: "12.34"}, + "long": &types.AttributeValueMemberN{Value: "45.78"}, + }}, + &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{ + "lat": &types.AttributeValueMemberN{Value: "11.22"}, + "long": &types.AttributeValueMemberN{Value: "33.44"}, + }}, + }}, + }}}, + + {name: "binary set", val: &types.AttributeValueMemberBS{Value: [][]byte{ + {1, 2, 3}, + {4, 5, 6}, + {7, 8, 9}, + }}}, + {name: "number set", val: &types.AttributeValueMemberNS{Value: []string{ + "123", + "456", + "789", + }}}, + {name: "string set", val: &types.AttributeValueMemberSS{Value: []string{ + "more", + "string", + "stuff", + }}}, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + bfr := new(bytes.Buffer) + + err := attrcodec.NewEncoder(bfr).Encode(scenario.val) + assert.NoError(t, err) + + t.Logf("length = %v", bfr.Len()) + + otherVal, err := attrcodec.NewDecoder(bfr).Decode() + assert.NoError(t, err) + assert.Equal(t, scenario.val, otherVal) + }) + } + }) +} diff --git a/internal/dynamo-browse/models/attrcodec/decoder.go b/internal/dynamo-browse/models/attrcodec/decoder.go new file mode 100644 index 0000000..ea0e081 --- /dev/null +++ b/internal/dynamo-browse/models/attrcodec/decoder.go @@ -0,0 +1,165 @@ +package attrcodec + +import ( + "encoding/binary" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/pkg/errors" + "io" +) + +type Decoder struct { + r io.Reader +} + +func NewDecoder(r io.Reader) *Decoder { + return &Decoder{r: r} +} + +func (d *Decoder) Decode() (types.AttributeValue, error) { + return d.decode() +} + +func (d *Decoder) decode() (types.AttributeValue, error) { + fr, err := d.readFrame() + if err != nil { + return nil, err + } + + switch fr.typeID { + case typeString: + return &types.AttributeValueMemberS{Value: string(fr.data)}, nil + case typeNumber: + return &types.AttributeValueMemberN{Value: string(fr.data)}, nil + case typeBoolean: + return &types.AttributeValueMemberBOOL{Value: fr.flags&flagsAlternative != 0}, nil + case typeNull: + return &types.AttributeValueMemberNULL{Value: fr.flags&flagsAlternative == 0}, nil + case typeBytes: + return &types.AttributeValueMemberB{Value: fr.data}, nil + case typeList: + vals := make([]types.AttributeValue, fr.length) + for i := range vals { + v, err := d.decode() + if err != nil { + return nil, err + } + vals[i] = v + } + return &types.AttributeValueMemberL{Value: vals}, nil + case typeMap: + vals := make(map[string]types.AttributeValue) + for i := 0; i < fr.length; i++ { + // key + keyFrame, err := d.readFrame() + if err != nil { + return nil, err + } else if keyFrame.typeID != typeString { + return nil, errors.Errorf("key of %v must be string, but is ID %v", i, keyFrame.typeID) + } + + // value + v, err := d.decode() + if err != nil { + return nil, err + } + vals[string(keyFrame.data)] = v + } + return &types.AttributeValueMemberM{Value: vals}, nil + case typeByteSet: + vals := make([][]byte, fr.length) + for i := range vals { + itemFrame, err := d.readFrame() + if err != nil { + return nil, err + } else if itemFrame.typeID != typeBytes { + return nil, errors.Errorf("item %v of byte-set must be bytes, but is ID %v", i, itemFrame.typeID) + } + + vals[i] = itemFrame.data + } + return &types.AttributeValueMemberBS{Value: vals}, nil + case typeNumberSet: + vals := make([]string, fr.length) + for i := range vals { + itemFrame, err := d.readFrame() + if err != nil { + return nil, err + } else if itemFrame.typeID != typeNumber { + return nil, errors.Errorf("item %v of number-set must be number, but is ID %v", i, itemFrame.typeID) + } + + vals[i] = string(itemFrame.data) + } + return &types.AttributeValueMemberNS{Value: vals}, nil + case typeStringSet: + vals := make([]string, fr.length) + for i := range vals { + itemFrame, err := d.readFrame() + if err != nil { + return nil, err + } else if itemFrame.typeID != typeString { + return nil, errors.Errorf("item %v of string-set must be number, but is ID %v", i, itemFrame.typeID) + } + + vals[i] = string(itemFrame.data) + } + return &types.AttributeValueMemberSS{Value: vals}, nil + } + + return nil, errors.Errorf("unrecognised type ID: %x", fr.typeID) +} + +func (d *Decoder) readFrame() (frame, error) { + var typeBfr [1]byte + + n, err := d.r.Read(typeBfr[:]) + if err != nil { + return frame{}, err + } else if n != 1 { + return frame{}, errors.New("expected frame typeID") + } + + typeID := typeBfr[0] &^ flagMask + flags := typeBfr[0] & flagMask + + typeInfo, hasTypeInfo := typeFrameInfos[typeID] + if !hasTypeInfo { + return frame{}, errors.Errorf("unrecognised typeID: %x", typeID) + } + + if typeInfo.isNilLength { + return frame{typeID: typeID, flags: flags, data: nil}, nil + } + + // TODO: this needs to depend on the type + var l int64 + if flags&flagsAlternative != 0 { + if err := binary.Read(d.r, byteOrder, &l); err != nil { + return frame{}, errors.Wrap(err, "cannot encode alt length") + } + } else { + var lenBfr [1]byte + + n, err := d.r.Read(lenBfr[:]) + if err != nil { + return frame{}, err + } else if n != 1 { + return frame{}, errors.New("expected frame typeID") + } + l = int64(lenBfr[0]) + } + + if typeInfo.lengthOnly { + return frame{typeID: typeID, flags: flags, length: int(l)}, nil + } + + bs := make([]byte, l) + n, err = d.r.Read(bs) + if err != nil { + return frame{}, err + } else if n != int(l) { + return frame{}, errors.Errorf("expected %v bytes but received %v", l, n) + } + + return frame{typeID: typeID, flags: flags, data: bs}, nil +} diff --git a/internal/dynamo-browse/models/attrcodec/encoder.go b/internal/dynamo-browse/models/attrcodec/encoder.go new file mode 100644 index 0000000..d1302d0 --- /dev/null +++ b/internal/dynamo-browse/models/attrcodec/encoder.go @@ -0,0 +1,139 @@ +package attrcodec + +import ( + "encoding/binary" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/pkg/errors" + "io" +) + +var byteOrder = binary.LittleEndian + +type Encoder struct { + w io.Writer +} + +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{w: w} +} + +func (e *Encoder) Encode(val types.AttributeValue) error { + return e.encode(val) +} + +func (e *Encoder) encode(val types.AttributeValue) error { + switch v := val.(type) { + case *types.AttributeValueMemberS: + return e.writeFrame(typeString, []byte(v.Value)) + case *types.AttributeValueMemberN: + return e.writeFrame(typeNumber, []byte(v.Value)) + case *types.AttributeValueMemberBOOL: + if v.Value { + return e.writeNilLengthFrame(typeBoolean, flagsAlternative) + } else { + return e.writeNilLengthFrame(typeBoolean, 0x0) + } + case *types.AttributeValueMemberNULL: + if !v.Value { + return e.writeNilLengthFrame(typeNull, flagsAlternative) + } else { + return e.writeNilLengthFrame(typeNull, 0x0) + } + case *types.AttributeValueMemberB: + return e.writeFrame(typeBytes, v.Value) + case *types.AttributeValueMemberL: + if err := e.writeFrameHeader(typeList, len(v.Value)); err != nil { + return err + } + for _, nv := range v.Value { + if err := e.encode(nv); err != nil { + return err + } + } + return nil + case *types.AttributeValueMemberM: + if err := e.writeFrameHeader(typeMap, len(v.Value)); err != nil { + return err + } + + for k, kv := range v.Value { + // Keys are always strings + if err := e.writeFrame(typeString, []byte(k)); err != nil { + return err + } + if err := e.encode(kv); err != nil { + return err + } + } + return nil + case *types.AttributeValueMemberBS: + if err := e.writeFrameHeader(typeByteSet, len(v.Value)); err != nil { + return err + } + for _, nv := range v.Value { + if err := e.writeFrame(typeBytes, nv); err != nil { + return err + } + } + return nil + case *types.AttributeValueMemberNS: + if err := e.writeFrameHeader(typeNumberSet, len(v.Value)); err != nil { + return err + } + for _, nv := range v.Value { + if err := e.writeFrame(typeNumber, []byte(nv)); err != nil { + return err + } + } + return nil + case *types.AttributeValueMemberSS: + if err := e.writeFrameHeader(typeStringSet, len(v.Value)); err != nil { + return err + } + for _, nv := range v.Value { + if err := e.writeFrame(typeString, []byte(nv)); err != nil { + return err + } + } + return nil + + } + return errors.New("unhandled type") +} + +func (e *Encoder) writeNilLengthFrame(typeID byte, flags byte) error { + if _, err := e.w.Write([]byte{typeID | flags}); err != nil { + return err + } + return nil +} + +func (e *Encoder) writeFrameHeader(typeID byte, length int) error { + if length <= 255 { + if _, err := e.w.Write([]byte{typeID, byte(length)}); err != nil { + return err + } + + return nil + } + + // Length longer than a byte, use a int32 + if _, err := e.w.Write([]byte{typeID | flagsAlternative}); err != nil { + return err + } + + if err := binary.Write(e.w, byteOrder, int64(length)); err != nil { + return errors.Wrap(err, "cannot encode alt length") + } + + return nil +} + +func (e *Encoder) writeFrame(typeID byte, bts []byte) error { + if err := e.writeFrameHeader(typeID, len(bts)); err != nil { + return err + } + + _, err := e.w.Write(bts) + return err +} diff --git a/internal/dynamo-browse/models/attrcodec/frames.go b/internal/dynamo-browse/models/attrcodec/frames.go new file mode 100644 index 0000000..3543ee4 --- /dev/null +++ b/internal/dynamo-browse/models/attrcodec/frames.go @@ -0,0 +1,43 @@ +package attrcodec + +const ( + typeString byte = 0x01 + typeNumber byte = 0x02 + typeBoolean byte = 0x03 + typeNull byte = 0x04 + typeList byte = 0x05 + typeMap byte = 0x06 + typeBytes byte = 0x07 + typeByteSet byte = 0x08 + typeNumberSet byte = 0x09 + typeStringSet byte = 0x0A + + flagMask = 0x80 + + flagsAlternative = 0x80 +) + +type frame struct { + typeID byte + flags byte + length int + data []byte +} + +type typeFrameInfo struct { + isNilLength bool + lengthOnly bool +} + +var typeFrameInfos = map[byte]typeFrameInfo{ + typeString: {}, + typeNumber: {}, + typeBoolean: {isNilLength: true}, + typeNull: {isNilLength: true}, + typeList: {lengthOnly: true}, + typeMap: {lengthOnly: true}, + typeBytes: {}, + typeByteSet: {lengthOnly: true}, + typeNumberSet: {lengthOnly: true}, + typeStringSet: {lengthOnly: true}, +} diff --git a/internal/dynamo-browse/models/attrutils/equals.go b/internal/dynamo-browse/models/attrutils/equals.go new file mode 100644 index 0000000..ec5ff30 --- /dev/null +++ b/internal/dynamo-browse/models/attrutils/equals.go @@ -0,0 +1,53 @@ +package attrutils + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" +) + +func Equals(x, y types.AttributeValue) bool { + switch xVal := x.(type) { + case *types.AttributeValueMemberS: + c, ok := CompareScalarAttributes(x, y) + return ok && c == 0 + case *types.AttributeValueMemberN: + c, ok := CompareScalarAttributes(x, y) + return ok && c == 0 + case *types.AttributeValueMemberBOOL: + c, ok := CompareScalarAttributes(x, y) + return ok && c == 0 + case *types.AttributeValueMemberB: + if yVal, ok := y.(*types.AttributeValueMemberB); ok { + return slices.Equal(xVal.Value, yVal.Value) + } + case *types.AttributeValueMemberNULL: + if yVal, ok := y.(*types.AttributeValueMemberNULL); ok { + return xVal.Value == yVal.Value + } + case *types.AttributeValueMemberL: + if yVal, ok := y.(*types.AttributeValueMemberL); ok { + return slices.EqualFunc(xVal.Value, yVal.Value, Equals) + } + case *types.AttributeValueMemberM: + if yVal, ok := y.(*types.AttributeValueMemberM); ok { + return maps.EqualFunc(xVal.Value, yVal.Value, Equals) + } + case *types.AttributeValueMemberBS: + if yVal, ok := y.(*types.AttributeValueMemberBS); ok { + return slices.EqualFunc(xVal.Value, yVal.Value, func(xs, ys []byte) bool { + return slices.Equal(xs, ys) + }) + } + case *types.AttributeValueMemberNS: + if yVal, ok := y.(*types.AttributeValueMemberNS); ok { + return slices.Equal(xVal.Value, yVal.Value) + } + case *types.AttributeValueMemberSS: + if yVal, ok := y.(*types.AttributeValueMemberSS); ok { + return slices.Equal(xVal.Value, yVal.Value) + } + } + + return false +} diff --git a/internal/dynamo-browse/models/attrutils/hash.go b/internal/dynamo-browse/models/attrutils/hash.go new file mode 100644 index 0000000..638f4c6 --- /dev/null +++ b/internal/dynamo-browse/models/attrutils/hash.go @@ -0,0 +1,68 @@ +package attrutils + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + "hash" + "hash/fnv" +) + +func HashCode(x types.AttributeValue) uint64 { + h := fnv.New64a() + doHash(x, h) + return h.Sum64() +} + +func HashTo(h hash.Hash, x types.AttributeValue) { + doHash(x, h) +} + +func doHash(x types.AttributeValue, h hash.Hash) { + switch xVal := x.(type) { + case *types.AttributeValueMemberS: + h.Write([]byte(xVal.Value)) + case *types.AttributeValueMemberN: + h.Write([]byte(xVal.Value)) + case *types.AttributeValueMemberBOOL: + if xVal.Value { + h.Write([]byte{0}) + } else { + h.Write([]byte{1}) + } + case *types.AttributeValueMemberB: + h.Write(xVal.Value) + case *types.AttributeValueMemberNULL: + if xVal.Value { + h.Write([]byte{0}) + } else { + h.Write([]byte{1}) + } + case *types.AttributeValueMemberL: + for _, v := range xVal.Value { + doHash(v, h) + } + case *types.AttributeValueMemberM: + // To keep this consistent, this will need to be in key sorted order + sortedKeys := make([]string, len(xVal.Value)) + copy(sortedKeys, maps.Keys(xVal.Value)) + slices.Sort(sortedKeys) + + for _, k := range sortedKeys { + h.Write([]byte(k)) + doHash(xVal.Value[k], h) + } + case *types.AttributeValueMemberBS: + for _, v := range xVal.Value { + h.Write(v) + } + case *types.AttributeValueMemberNS: + for _, v := range xVal.Value { + h.Write([]byte(v)) + } + case *types.AttributeValueMemberSS: + for _, v := range xVal.Value { + h.Write([]byte(v)) + } + } +} diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index c13b901..258b247 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -13,6 +13,8 @@ type ResultSet struct { type Queryable interface { String() string + SerializeToBytes() ([]byte, error) + HashCode() uint64 Plan(tableInfo *TableInfo) (*QueryExecutionPlan, error) } diff --git a/internal/dynamo-browse/models/queryexpr/ast.go b/internal/dynamo-browse/models/queryexpr/ast.go index 7355fb4..fde7c46 100644 --- a/internal/dynamo-browse/models/queryexpr/ast.go +++ b/internal/dynamo-browse/models/queryexpr/ast.go @@ -49,9 +49,14 @@ type astEqualityOp struct { } type astIsOp struct { - Ref *astFunctionCall `parser:"@@ ( 'is' "` - HasNot bool `parser:"@'not'?"` - Value *astFunctionCall `parser:"@@ )?"` + Ref *astSubRef `parser:"@@ ( 'is' "` + HasNot bool `parser:"@'not'?"` + Value *astSubRef `parser:"@@ )?"` +} + +type astSubRef struct { + Ref *astFunctionCall `parser:"@@"` + Quals []string `parser:"('.' @Ident)*"` } type astFunctionCall struct { @@ -61,14 +66,18 @@ type astFunctionCall struct { } type astAtom struct { - Ref *astDot `parser:"@@ | "` - Literal *astLiteralValue `parser:"@@ | "` - Paren *astExpr `parser:"'(' @@ ')'"` + Ref *astRef `parser:"@@ | "` + Literal *astLiteralValue `parser:"@@ | "` + Placeholder *astPlaceholder `parser:"@@ | "` + Paren *astExpr `parser:"'(' @@ ')'"` } -type astDot struct { - Name string `parser:"@Ident"` - Quals []string `parser:"('.' @Ident)*"` +type astRef struct { + Name string `parser:"@Ident"` +} + +type astPlaceholder struct { + Placeholder string `parser:"@PlaceholderIdent"` } type astLiteralValue struct { @@ -83,6 +92,7 @@ var scanner = lexer.MustSimple([]lexer.SimpleRule{ {Name: "Int", Pattern: `[-+]?(\d*\.)?\d+`}, {Name: "Number", Pattern: `[-+]?(\d*\.)?\d+`}, {Name: "Ident", Pattern: `[a-zA-Z_][a-zA-Z0-9_-]*`}, + {Name: "PlaceholderIdent", Pattern: `[$:][a-zA-Z0-9_-][a-zA-Z0-9_-]*`}, {Name: "Punct", Pattern: `[-[!@#$%^&*()+_={}\|:;"'<,>.?/]|][=]?`}, {Name: "EOL", Pattern: `[\n\r]+`}, {Name: "whitespace", Pattern: `[ \t]+`}, @@ -100,8 +110,8 @@ func Parse(expr string) (*QueryExpr, error) { return &QueryExpr{ast: ast}, nil } -func (a *astExpr) calcQuery(info *models.TableInfo) (*models.QueryExecutionPlan, error) { - ir, err := a.evalToIR(info) +func (a *astExpr) calcQuery(ctx *evalContext, info *models.TableInfo) (*models.QueryExecutionPlan, error) { + ir, err := a.evalToIR(ctx, info) if err != nil { return nil, err } @@ -146,10 +156,22 @@ func (a *astExpr) calcQuery(info *models.TableInfo) (*models.QueryExecutionPlan, }, nil } -func (a *astExpr) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { - return a.Root.evalToIR(tableInfo) +func (a *astExpr) evalToIR(ctx *evalContext, tableInfo *models.TableInfo) (irAtom, error) { + return a.Root.evalToIR(ctx, tableInfo) } -func (a *astExpr) evalItem(item models.Item) (types.AttributeValue, error) { - return a.Root.evalItem(item) +func (a *astExpr) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + return a.Root.evalItem(ctx, item) +} + +func (a *astExpr) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + return a.Root.setEvalItem(ctx, item, value) +} + +func (a *astExpr) deleteAttribute(ctx *evalContext, item models.Item) error { + return a.Root.deleteAttribute(ctx, item) +} + +func (md *astExpr) canModifyItem(ctx *evalContext, item models.Item) bool { + return md.Root.canModifyItem(ctx, item) } diff --git a/internal/dynamo-browse/models/queryexpr/atom.go b/internal/dynamo-browse/models/queryexpr/atom.go index 8b26858..8b4487e 100644 --- a/internal/dynamo-browse/models/queryexpr/atom.go +++ b/internal/dynamo-browse/models/queryexpr/atom.go @@ -6,14 +6,16 @@ import ( "github.com/pkg/errors" ) -func (a *astAtom) evalToIR(info *models.TableInfo) (irAtom, error) { +func (a *astAtom) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { switch { case a.Ref != nil: - return a.Ref.evalToIR(info) + return a.Ref.evalToIR(ctx, info) case a.Literal != nil: - return a.Literal.evalToIR(info) + return a.Literal.evalToIR(ctx, info) + case a.Placeholder != nil: + return a.Placeholder.evalToIR(ctx, info) case a.Paren != nil: - return a.Paren.evalToIR(info) + return a.Paren.evalToIR(ctx, info) } return nil, errors.New("unhandled atom case") @@ -37,19 +39,57 @@ func (a *astAtom) unqualifiedName() (string, bool) { return "", false } -func (a *astAtom) evalItem(item models.Item) (types.AttributeValue, error) { +func (a *astAtom) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { switch { case a.Ref != nil: - return a.Ref.evalItem(item) + return a.Ref.evalItem(ctx, item) case a.Literal != nil: return a.Literal.dynamoValue() + case a.Placeholder != nil: + return a.Placeholder.evalItem(ctx, item) case a.Paren != nil: - return a.Paren.evalItem(item) + return a.Paren.evalItem(ctx, item) } return nil, errors.New("unhandled atom case") } +func (a *astAtom) canModifyItem(ctx *evalContext, item models.Item) bool { + switch { + case a.Ref != nil: + return a.Ref.canModifyItem(ctx, item) + case a.Placeholder != nil: + return a.Placeholder.canModifyItem(ctx, item) + case a.Paren != nil: + return a.Paren.canModifyItem(ctx, item) + } + return false +} + +func (a *astAtom) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + switch { + case a.Ref != nil: + return a.Ref.setEvalItem(ctx, item, value) + case a.Placeholder != nil: + return a.Placeholder.setEvalItem(ctx, item, value) + case a.Paren != nil: + return a.Paren.setEvalItem(ctx, item, value) + } + return PathNotSettableError{} +} + +func (a *astAtom) deleteAttribute(ctx *evalContext, item models.Item) error { + switch { + case a.Ref != nil: + return a.Ref.deleteAttribute(ctx, item) + case a.Paren != nil: + return a.Paren.deleteAttribute(ctx, item) + case a.Placeholder != nil: + return a.Placeholder.deleteAttribute(ctx, item) + } + return PathNotSettableError{} +} + func (a *astAtom) String() string { switch { case a.Ref != nil: @@ -58,6 +98,8 @@ func (a *astAtom) String() string { return a.Literal.String() case a.Paren != nil: return "(" + a.Paren.String() + ")" + case a.Placeholder != nil: + return a.Placeholder.String() } return "" } diff --git a/internal/dynamo-browse/models/queryexpr/boolnot.go b/internal/dynamo-browse/models/queryexpr/boolnot.go index 32eee69..9c71f79 100644 --- a/internal/dynamo-browse/models/queryexpr/boolnot.go +++ b/internal/dynamo-browse/models/queryexpr/boolnot.go @@ -7,8 +7,8 @@ import ( "strings" ) -func (a *astBooleanNot) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { - irNode, err := a.Operand.evalToIR(tableInfo) +func (a *astBooleanNot) evalToIR(ctx *evalContext, tableInfo *models.TableInfo) (irAtom, error) { + irNode, err := a.Operand.evalToIR(ctx, tableInfo) if err != nil { return nil, err } @@ -20,8 +20,8 @@ func (a *astBooleanNot) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { return &irBoolNot{atom: irNode}, nil } -func (a *astBooleanNot) evalItem(item models.Item) (types.AttributeValue, error) { - val, err := a.Operand.evalItem(item) +func (a *astBooleanNot) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + val, err := a.Operand.evalItem(ctx, item) if err != nil { return nil, err } @@ -33,6 +33,27 @@ func (a *astBooleanNot) evalItem(item models.Item) (types.AttributeValue, error) return &types.AttributeValueMemberBOOL{Value: !isAttributeTrue(val)}, nil } +func (a *astBooleanNot) canModifyItem(ctx *evalContext, item models.Item) bool { + if a.HasNot { + return false + } + return a.Operand.canModifyItem(ctx, item) +} + +func (a *astBooleanNot) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if a.HasNot { + return PathNotSettableError{} + } + return a.Operand.setEvalItem(ctx, item, value) +} + +func (a *astBooleanNot) deleteAttribute(ctx *evalContext, item models.Item) error { + if a.HasNot { + return PathNotSettableError{} + } + return a.Operand.deleteAttribute(ctx, item) +} + func (d *astBooleanNot) String() string { sb := new(strings.Builder) if d.HasNot { diff --git a/internal/dynamo-browse/models/queryexpr/comp.go b/internal/dynamo-browse/models/queryexpr/comp.go index a90b858..efc6176 100644 --- a/internal/dynamo-browse/models/queryexpr/comp.go +++ b/internal/dynamo-browse/models/queryexpr/comp.go @@ -8,8 +8,8 @@ import ( "github.com/pkg/errors" ) -func (a *astComparisonOp) evalToIR(info *models.TableInfo) (irAtom, error) { - leftIR, err := a.Ref.evalToIR(info) +func (a *astComparisonOp) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + leftIR, err := a.Ref.evalToIR(ctx, info) if err != nil { return nil, err } @@ -28,7 +28,7 @@ func (a *astComparisonOp) evalToIR(info *models.TableInfo) (irAtom, error) { return nil, OperandNotAnOperandError{} } - rightIR, err := a.Value.evalToIR(info) + rightIR, err := a.Value.evalToIR(ctx, info) if err != nil { return nil, err } @@ -47,8 +47,8 @@ func (a *astComparisonOp) evalToIR(info *models.TableInfo) (irAtom, error) { return irGenericCmp{leftOpr, rightOpr, cmpType}, nil } -func (a *astComparisonOp) evalItem(item models.Item) (types.AttributeValue, error) { - left, err := a.Ref.evalItem(item) +func (a *astComparisonOp) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + left, err := a.Ref.evalItem(ctx, item) if err != nil { return nil, err } @@ -56,7 +56,7 @@ func (a *astComparisonOp) evalItem(item models.Item) (types.AttributeValue, erro return left, nil } - right, err := a.Value.evalItem(item) + right, err := a.Value.evalItem(ctx, item) if err != nil { return nil, err } @@ -79,6 +79,28 @@ func (a *astComparisonOp) evalItem(item models.Item) (types.AttributeValue, erro return nil, errors.Errorf("unrecognised operator: %v", a.Op) } +func (a *astComparisonOp) canModifyItem(ctx *evalContext, item models.Item) bool { + if a.Op != "" { + return false + } + return a.Ref.canModifyItem(ctx, item) +} + +func (a *astComparisonOp) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if a.Op != "" { + return PathNotSettableError{} + } + return a.Ref.setEvalItem(ctx, item, value) +} + +func (a *astComparisonOp) deleteAttribute(ctx *evalContext, item models.Item) error { + if a.Op != "" { + return PathNotSettableError{} + } + return a.Ref.deleteAttribute(ctx, item) + +} + func (a *astComparisonOp) String() string { if a.Op == "" { return a.Ref.String() diff --git a/internal/dynamo-browse/models/queryexpr/conj.go b/internal/dynamo-browse/models/queryexpr/conj.go index 614bf0f..7f23135 100644 --- a/internal/dynamo-browse/models/queryexpr/conj.go +++ b/internal/dynamo-browse/models/queryexpr/conj.go @@ -7,16 +7,16 @@ import ( "strings" ) -func (a *astConjunction) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { +func (a *astConjunction) evalToIR(ctx *evalContext, tableInfo *models.TableInfo) (irAtom, error) { if len(a.Operands) == 1 { - return a.Operands[0].evalToIR(tableInfo) + return a.Operands[0].evalToIR(ctx, tableInfo) } else if len(a.Operands) == 2 { - left, err := a.Operands[0].evalToIR(tableInfo) + left, err := a.Operands[0].evalToIR(ctx, tableInfo) if err != nil { return nil, err } - right, err := a.Operands[1].evalToIR(tableInfo) + right, err := a.Operands[1].evalToIR(ctx, tableInfo) if err != nil { return nil, err } @@ -27,7 +27,7 @@ func (a *astConjunction) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { atoms := make([]irAtom, len(a.Operands)) for i, op := range a.Operands { var err error - atoms[i], err = op.evalToIR(tableInfo) + atoms[i], err = op.evalToIR(ctx, tableInfo) if err != nil { return nil, err } @@ -36,8 +36,8 @@ func (a *astConjunction) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { return &irMultiConjunction{atoms: atoms}, nil } -func (a *astConjunction) evalItem(item models.Item) (types.AttributeValue, error) { - val, err := a.Operands[0].evalItem(item) +func (a *astConjunction) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + val, err := a.Operands[0].evalItem(ctx, item) if err != nil { return nil, err } @@ -50,7 +50,7 @@ func (a *astConjunction) evalItem(item models.Item) (types.AttributeValue, error return &types.AttributeValueMemberBOOL{Value: false}, nil } - val, err = opr.evalItem(item) + val, err = opr.evalItem(ctx, item) if err != nil { return nil, err } @@ -59,6 +59,30 @@ func (a *astConjunction) evalItem(item models.Item) (types.AttributeValue, error return &types.AttributeValueMemberBOOL{Value: isAttributeTrue(val)}, nil } +func (a *astConjunction) canModifyItem(ctx *evalContext, item models.Item) bool { + if len(a.Operands) == 1 { + return a.Operands[0].canModifyItem(ctx, item) + } + + return false +} + +func (a *astConjunction) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if len(a.Operands) == 1 { + return a.Operands[0].setEvalItem(ctx, item, value) + } + + return PathNotSettableError{} +} + +func (a *astConjunction) deleteAttribute(ctx *evalContext, item models.Item) error { + if len(a.Operands) == 1 { + return a.Operands[0].deleteAttribute(ctx, item) + } + + return PathNotSettableError{} +} + func (d *astConjunction) String() string { sb := new(strings.Builder) for i, operand := range d.Operands { diff --git a/internal/dynamo-browse/models/queryexpr/disj.go b/internal/dynamo-browse/models/queryexpr/disj.go index 30b5a6f..913a503 100644 --- a/internal/dynamo-browse/models/queryexpr/disj.go +++ b/internal/dynamo-browse/models/queryexpr/disj.go @@ -7,15 +7,15 @@ import ( "strings" ) -func (a *astDisjunction) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { +func (a *astDisjunction) evalToIR(ctx *evalContext, tableInfo *models.TableInfo) (irAtom, error) { if len(a.Operands) == 1 { - return a.Operands[0].evalToIR(tableInfo) + return a.Operands[0].evalToIR(ctx, tableInfo) } conj := make([]irAtom, len(a.Operands)) for i, op := range a.Operands { var err error - conj[i], err = op.evalToIR(tableInfo) + conj[i], err = op.evalToIR(ctx, tableInfo) if err != nil { return nil, err } @@ -24,8 +24,8 @@ func (a *astDisjunction) evalToIR(tableInfo *models.TableInfo) (irAtom, error) { return &irDisjunction{conj: conj}, nil } -func (a *astDisjunction) evalItem(item models.Item) (types.AttributeValue, error) { - val, err := a.Operands[0].evalItem(item) +func (a *astDisjunction) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + val, err := a.Operands[0].evalItem(ctx, item) if err != nil { return nil, err } @@ -38,7 +38,7 @@ func (a *astDisjunction) evalItem(item models.Item) (types.AttributeValue, error return &types.AttributeValueMemberBOOL{Value: true}, nil } - val, err = opr.evalItem(item) + val, err = opr.evalItem(ctx, item) if err != nil { return nil, err } @@ -47,6 +47,30 @@ func (a *astDisjunction) evalItem(item models.Item) (types.AttributeValue, error return &types.AttributeValueMemberBOOL{Value: isAttributeTrue(val)}, nil } +func (a *astDisjunction) canModifyItem(ctx *evalContext, item models.Item) bool { + if len(a.Operands) == 1 { + return a.Operands[0].canModifyItem(ctx, item) + } + + return false +} + +func (a *astDisjunction) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if len(a.Operands) == 1 { + return a.Operands[0].setEvalItem(ctx, item, value) + } + + return PathNotSettableError{} +} + +func (a *astDisjunction) deleteAttribute(ctx *evalContext, item models.Item) error { + if len(a.Operands) == 1 { + return a.Operands[0].deleteAttribute(ctx, item) + } + + return PathNotSettableError{} +} + func (d *astDisjunction) String() string { sb := new(strings.Builder) for i, operand := range d.Operands { diff --git a/internal/dynamo-browse/models/queryexpr/dot.go b/internal/dynamo-browse/models/queryexpr/dot.go index f6906ba..8ab6ef6 100644 --- a/internal/dynamo-browse/models/queryexpr/dot.go +++ b/internal/dynamo-browse/models/queryexpr/dot.go @@ -4,51 +4,41 @@ import ( "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/audax/internal/dynamo-browse/models" - "strings" ) -func (dt *astDot) evalToIR(info *models.TableInfo) (irAtom, error) { - return irNamePath{dt.Name, dt.Quals}, nil +func (dt *astRef) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + return irNamePath{name: dt.Name}, nil } -func (dt *astDot) unqualifiedName() (string, bool) { - if len(dt.Quals) == 0 { - return dt.Name, true - } - return "", false +func (dt *astRef) unqualifiedName() (string, bool) { + return dt.Name, true } -func (dt *astDot) evalItem(item models.Item) (types.AttributeValue, error) { +func (dt *astRef) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { res, hasV := item[dt.Name] if !hasV { return nil, nil } - for i, qualName := range dt.Quals { - mapRes, isMapRes := res.(*types.AttributeValueMemberM) - if !isMapRes { - return nil, ValueNotAMapError(append([]string{dt.Name}, dt.Quals[:i+1]...)) - } - - res, hasV = mapRes.Value[qualName] - if !hasV { - return nil, nil - } - } - return res, nil } -func (a *astDot) String() string { - var sb strings.Builder +func (dt *astRef) canModifyItem(ctx *evalContext, item models.Item) bool { + return true +} - sb.WriteString(a.Name) - for _, q := range a.Quals { - sb.WriteRune('.') - sb.WriteString(q) - } +func (dt *astRef) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + item[dt.Name] = value + return nil +} - return sb.String() +func (dt *astRef) deleteAttribute(ctx *evalContext, item models.Item) error { + delete(item, dt.Name) + return nil +} + +func (a *astRef) String() string { + return a.Name } type irNamePath struct { diff --git a/internal/dynamo-browse/models/queryexpr/equality.go b/internal/dynamo-browse/models/queryexpr/equality.go index 284e9f5..7f1420e 100644 --- a/internal/dynamo-browse/models/queryexpr/equality.go +++ b/internal/dynamo-browse/models/queryexpr/equality.go @@ -9,8 +9,8 @@ import ( "strings" ) -func (a *astEqualityOp) evalToIR(info *models.TableInfo) (irAtom, error) { - leftIR, err := a.Ref.evalToIR(info) +func (a *astEqualityOp) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + leftIR, err := a.Ref.evalToIR(ctx, info) if err != nil { return nil, err } @@ -24,7 +24,7 @@ func (a *astEqualityOp) evalToIR(info *models.TableInfo) (irAtom, error) { return nil, OperandNotAnOperandError{} } - rightIR, err := a.Value.evalToIR(info) + rightIR, err := a.Value.evalToIR(ctx, info) if err != nil { return nil, err } @@ -59,8 +59,8 @@ func (a *astEqualityOp) evalToIR(info *models.TableInfo) (irAtom, error) { return nil, errors.Errorf("unrecognised operator: %v", a.Op) } -func (a *astEqualityOp) evalItem(item models.Item) (types.AttributeValue, error) { - left, err := a.Ref.evalItem(item) +func (a *astEqualityOp) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + left, err := a.Ref.evalItem(ctx, item) if err != nil { return nil, err } @@ -69,7 +69,7 @@ func (a *astEqualityOp) evalItem(item models.Item) (types.AttributeValue, error) return left, nil } - right, err := a.Value.evalItem(item) + right, err := a.Value.evalItem(ctx, item) if err != nil { return nil, err } @@ -103,6 +103,27 @@ func (a *astEqualityOp) evalItem(item models.Item) (types.AttributeValue, error) return nil, errors.Errorf("unrecognised operator: %v", a.Op) } +func (a *astEqualityOp) canModifyItem(ctx *evalContext, item models.Item) bool { + if a.Op != "" { + return false + } + return a.Ref.canModifyItem(ctx, item) +} + +func (a *astEqualityOp) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if a.Op != "" { + return PathNotSettableError{} + } + return a.Ref.setEvalItem(ctx, item, value) +} + +func (a *astEqualityOp) deleteAttribute(ctx *evalContext, item models.Item) error { + if a.Op != "" { + return PathNotSettableError{} + } + return a.Ref.deleteAttribute(ctx, item) +} + func (a *astEqualityOp) String() string { if a.Op == "" { return a.Ref.String() diff --git a/internal/dynamo-browse/models/queryexpr/errors.go b/internal/dynamo-browse/models/queryexpr/errors.go index 9df428f..57fc0f4 100644 --- a/internal/dynamo-browse/models/queryexpr/errors.go +++ b/internal/dynamo-browse/models/queryexpr/errors.go @@ -108,3 +108,18 @@ type UnrecognisedFunctionError struct { func (e UnrecognisedFunctionError) Error() string { return "unrecognised function '" + e.Name + "'" } + +type PathNotSettableError struct { +} + +func (e PathNotSettableError) Error() string { + return "path cannot be set a value" +} + +type MissingPlaceholderError struct { + Placeholder string +} + +func (e MissingPlaceholderError) Error() string { + return "undefined placeholder '" + e.Placeholder + "'" +} diff --git a/internal/dynamo-browse/models/queryexpr/expr.go b/internal/dynamo-browse/models/queryexpr/expr.go index 4e3d945..f4055b9 100644 --- a/internal/dynamo-browse/models/queryexpr/expr.go +++ b/internal/dynamo-browse/models/queryexpr/expr.go @@ -1,20 +1,193 @@ package queryexpr import ( + "bytes" + "encoding/gob" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/attrcodec" + "github.com/lmika/audax/internal/dynamo-browse/models/attrutils" + "github.com/pkg/errors" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + "hash/fnv" + "io" ) type QueryExpr struct { - ast *astExpr + ast *astExpr + names map[string]string + values map[string]types.AttributeValue +} + +type serializedExpr struct { + Expr string + Names map[string]string + Values []byte +} + +func DeserializeFrom(r io.Reader) (*QueryExpr, error) { + var se serializedExpr + + if err := gob.NewDecoder(r).Decode(&se); err != nil { + return nil, err + } + + qe, err := Parse(se.Expr) + if err != nil { + return nil, err + } + + qe.names = se.Names + + if len(se.Values) > 0 { + vals, err := attrcodec.NewDecoder(bytes.NewReader(se.Values)).Decode() + if err != nil { + return nil, errors.Wrap(err, "unable to marshal placeholder values") + } + mvals, ok := vals.(*types.AttributeValueMemberM) + if !ok { + return nil, errors.Errorf("expected marshaled placeholder values to be map, but was %T", vals) + } + qe.values = mvals.Value + } + + return qe, nil +} + +func (md *QueryExpr) SerializeTo(w io.Writer) error { + se := serializedExpr{Expr: md.String(), Names: md.names} + if md.values != nil { + var bts bytes.Buffer + if err := attrcodec.NewEncoder(&bts).Encode(&types.AttributeValueMemberM{Value: md.values}); err != nil { + return errors.Wrap(err, "unable to unmarshal placeholder values") + } + se.Values = bts.Bytes() + } + + return gob.NewEncoder(w).Encode(se) +} + +func (md *QueryExpr) SerializeToBytes() ([]byte, error) { + if md == nil { + return nil, nil + } + var bfr bytes.Buffer + + if err := md.SerializeTo(&bfr); err != nil { + return nil, err + } + return bfr.Bytes(), nil +} + +// Equal returns true if a query expression is equal another one. Two query expressions are equal if they +// have the same query and placeholder values. This is resistant to map ordering. +func (md *QueryExpr) Equal(other *QueryExpr) bool { + if md == nil { + return other == nil + } else if other == nil { + return false + } + + return md.ast.String() == other.ast.String() && + maps.Equal(md.names, other.names) && + maps.EqualFunc(md.values, md.values, attrutils.Equals) +} + +// HashCode will return a hash-code for this query expression. This is to assist with determine whether two +// queries are the same. If two queries have the same hash code, they may be equals (this will need to be +// confirmed by calling Equal()). Otherwise, the queries cannot be equals. +func (md *QueryExpr) HashCode() uint64 { + if md == nil { + return 0 + } + + h := fnv.New64a() + h.Write([]byte(md.ast.String())) + + // the names must be in sorted order to maintain consistant key ordering + if len(md.names) > 0 { + sortedKeys := make([]string, len(md.names)) + copy(sortedKeys, maps.Keys(md.names)) + slices.Sort(sortedKeys) + + for _, k := range sortedKeys { + h.Write([]byte(k)) + h.Write([]byte(md.names[k])) + } + } + + if len(md.values) > 0 { + sortedKeys := make([]string, len(md.values)) + copy(sortedKeys, maps.Keys(md.values)) + slices.Sort(sortedKeys) + + for _, k := range sortedKeys { + h.Write([]byte(k)) + attrutils.HashTo(h, md.values[k]) + } + } + + return h.Sum64() +} + +func (md *QueryExpr) WithNameParams(value map[string]string) *QueryExpr { + return &QueryExpr{ + ast: md.ast, + names: value, + values: md.values, + } +} + +func (md *QueryExpr) NameParam(name string) (string, bool) { + return md.evalContext().lookupName(name) +} + +func (md *QueryExpr) ValueParam(name string) (types.AttributeValue, bool) { + return md.evalContext().lookupValue(name) +} + +func (md *QueryExpr) ValueParamOrNil(name string) types.AttributeValue { + v, ok := md.ValueParam(name) + if !ok { + return nil + } + return v +} + +func (md *QueryExpr) WithValueParams(value map[string]types.AttributeValue) *QueryExpr { + return &QueryExpr{ + ast: md.ast, + names: md.names, + values: value, + } } func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPlan, error) { - return md.ast.calcQuery(tableInfo) + return md.ast.calcQuery(md.evalContext(), tableInfo) } func (md *QueryExpr) EvalItem(item models.Item) (types.AttributeValue, error) { - return md.ast.evalItem(item) + return md.ast.evalItem(md.evalContext(), item) +} + +func (md *QueryExpr) DeleteAttribute(item models.Item) error { + return md.ast.deleteAttribute(md.evalContext(), item) +} + +func (md *QueryExpr) SetEvalItem(item models.Item, newValue types.AttributeValue) error { + return md.ast.setEvalItem(md.evalContext(), item, newValue) +} + +func (md *QueryExpr) IsModifiablePath(item models.Item) bool { + return md.ast.canModifyItem(md.evalContext(), item) +} + +func (md *QueryExpr) evalContext() *evalContext { + return &evalContext{ + namePlaceholders: md.names, + valuePlaceholders: md.values, + } } func (md *QueryExpr) String() string { @@ -57,3 +230,36 @@ func (qc *queryCalcInfo) addKey(tableInfo *models.TableInfo, key string) bool { qc.seenKeys[key] = struct{}{} return true } + +type evalContext struct { + namePlaceholders map[string]string + nameLookup func(string) (string, bool) + valuePlaceholders map[string]types.AttributeValue + valueLookup func(string) (types.AttributeValue, bool) +} + +func (ec *evalContext) lookupName(name string) (string, bool) { + val, hasVal := ec.namePlaceholders[name] + if hasVal { + return val, true + } + + if fn := ec.nameLookup; fn != nil { + return fn(name) + } + + return "", false +} + +func (ec *evalContext) lookupValue(name string) (types.AttributeValue, bool) { + val, hasVal := ec.valuePlaceholders[name] + if hasVal { + return val, true + } + + if fn := ec.valueLookup; fn != nil { + return fn(name) + } + + return nil, false +} diff --git a/internal/dynamo-browse/models/queryexpr/expr_test.go b/internal/dynamo-browse/models/queryexpr/expr_test.go index b559b6c..953a0ff 100644 --- a/internal/dynamo-browse/models/queryexpr/expr_test.go +++ b/internal/dynamo-browse/models/queryexpr/expr_test.go @@ -1,6 +1,7 @@ package queryexpr_test import ( + "bytes" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" @@ -95,6 +96,21 @@ func TestModExpr_Query(t *testing.T) { exprNameIsString(0, 0, "pk", "prefix"), exprNameIsNumber(1, 1, "sk", "100"), ), + + scanCase("with placeholders", + `:partition=$valuePrefix and :sort=$valueAnother`, + `(#0 = :0) AND (#1 = :1)`, + placeholderNames(map[string]string{ + "partition": "pk", + "sort": "sk", + }), + placeholderValues(map[string]types.AttributeValue{ + "valuePrefix": &types.AttributeValueMemberS{Value: "prefix"}, + "valueAnother": &types.AttributeValueMemberS{Value: "another"}, + }), + exprNameIsString(0, 0, "pk", "prefix"), + exprNameIsString(1, 1, "sk", "another"), + ), } for _, scenario := range scenarios { @@ -102,6 +118,8 @@ func TestModExpr_Query(t *testing.T) { modExpr, err := queryexpr.Parse(scenario.expression) assert.NoError(t, err) + modExpr = modExpr.WithNameParams(scenario.placeholderNames).WithValueParams(scenario.placeholderValues) + plan, err := modExpr.Plan(tableInfo) assert.NoError(t, err) @@ -242,7 +260,37 @@ func TestModExpr_Query(t *testing.T) { exprValueIsNumber(0, "131"), ), + // Dots + scanCase("with the dot", `this.value = "something"`, `#0.#1 = :0`, + exprName(0, "this"), + exprName(1, "value"), + exprValueIsString(0, "something"), + ), + scanCase("with multiple dots", `this.that.other.value = "else"`, `#0.#1.#2.#3 = :0`, + exprName(0, "this"), + exprName(1, "that"), + exprName(2, "other"), + exprName(3, "value"), + exprValueIsString(0, "else"), + ), + // TODO: the contains function + + // Placeholders + scanCase("with placeholders", + `:partition=$valuePrefix or :sort=$valueAnother`, + `(#0 = :0) OR (#1 = :1)`, + placeholderNames(map[string]string{ + "partition": "pk", + "sort": "sk", + }), + placeholderValues(map[string]types.AttributeValue{ + "valuePrefix": &types.AttributeValueMemberS{Value: "prefix"}, + "valueAnother": &types.AttributeValueMemberS{Value: "another"}, + }), + exprNameIsString(0, 0, "pk", "prefix"), + exprNameIsString(1, 1, "sk", "another"), + ), } for _, scenario := range scenarios { @@ -250,6 +298,8 @@ func TestModExpr_Query(t *testing.T) { modExpr, err := queryexpr.Parse(scenario.expression) assert.NoError(t, err) + modExpr = modExpr.WithNameParams(scenario.placeholderNames).WithValueParams(scenario.placeholderValues) + plan, err := modExpr.Plan(tableInfo) assert.NoError(t, err) @@ -359,6 +409,7 @@ func TestQueryExpr_EvalItem(t *testing.T) { // Dot values {expr: `charlie.door`, expected: &types.AttributeValueMemberS{Value: "red"}}, + {expr: `(charlie).door`, expected: &types.AttributeValueMemberS{Value: "red"}}, {expr: `charlie.tree`, expected: &types.AttributeValueMemberS{Value: "green"}}, // Conjunction @@ -434,14 +485,321 @@ func TestQueryExpr_EvalItem(t *testing.T) { }) } }) + + t.Run("name and value placeholders", func(t *testing.T) { + scenarios := []struct { + expr string + expected types.AttributeValue + }{ + {expr: `alpha = $a`, expected: &types.AttributeValueMemberBOOL{Value: true}}, + {expr: `:theBName = 123`, expected: &types.AttributeValueMemberBOOL{Value: true}}, + {expr: `:theCMap.door`, expected: &types.AttributeValueMemberS{Value: "red"}}, + } + + for _, scenario := range scenarios { + t.Run(scenario.expr, func(t *testing.T) { + modExpr, err := queryexpr.Parse(scenario.expr) + assert.NoError(t, err) + + modExpr = modExpr.WithValueParams(map[string]types.AttributeValue{ + "a": &types.AttributeValueMemberS{Value: "alpha"}, + }).WithNameParams(map[string]string{ + "theBName": "bravo", + "theCMap": "charlie", + }) + + res, err := modExpr.EvalItem(item) + assert.NoError(t, err) + + assert.Equal(t, scenario.expected, res) + }) + } + }) +} + +func TestQueryExpr_SetEvalItem(t *testing.T) { + var templateItem = func() models.Item { + return models.Item{ + "alpha": &types.AttributeValueMemberS{Value: "alpha"}, + "bravo": &types.AttributeValueMemberN{Value: "123"}, + "charlie": &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "door": &types.AttributeValueMemberS{Value: "red"}, + "tree": &types.AttributeValueMemberS{Value: "green"}, + }, + }, + "prime": &types.AttributeValueMemberL{ + Value: []types.AttributeValue{ + &types.AttributeValueMemberN{Value: "2"}, + &types.AttributeValueMemberN{Value: "3"}, + &types.AttributeValueMemberN{Value: "5"}, + &types.AttributeValueMemberN{Value: "7"}, + }, + }, + "three": &types.AttributeValueMemberN{Value: "3"}, + } + } + + t.Run("simple values", func(t *testing.T) { + item := templateItem() + + modExpr, err := queryexpr.Parse("alpha") + assert.NoError(t, err) + assert.True(t, modExpr.IsModifiablePath(item)) + + err = modExpr.SetEvalItem(item, &types.AttributeValueMemberS{Value: "not alpha"}) + assert.NoError(t, err) + assert.Equal(t, "not alpha", item["alpha"].(*types.AttributeValueMemberS).Value) + }) + + t.Run("dot values", func(t *testing.T) { + item := templateItem() + + modExpr, err := queryexpr.Parse("charlie.tree") + assert.NoError(t, err) + assert.True(t, modExpr.IsModifiablePath(item)) + + err = modExpr.SetEvalItem(item, &types.AttributeValueMemberS{Value: "Birch"}) + assert.NoError(t, err) + assert.Equal(t, "Birch", item["charlie"].(*types.AttributeValueMemberM).Value["tree"].(*types.AttributeValueMemberS).Value) + }) +} + +func TestQueryExpr_DeleteAttribute(t *testing.T) { + var templateItem = func() models.Item { + return models.Item{ + "alpha": &types.AttributeValueMemberS{Value: "alpha"}, + "bravo": &types.AttributeValueMemberN{Value: "123"}, + "charlie": &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "door": &types.AttributeValueMemberS{Value: "red"}, + "tree": &types.AttributeValueMemberS{Value: "green"}, + }, + }, + "prime": &types.AttributeValueMemberL{ + Value: []types.AttributeValue{ + &types.AttributeValueMemberN{Value: "2"}, + &types.AttributeValueMemberN{Value: "3"}, + &types.AttributeValueMemberN{Value: "5"}, + &types.AttributeValueMemberN{Value: "7"}, + }, + }, + "three": &types.AttributeValueMemberN{Value: "3"}, + } + } + + t.Run("simple values", func(t *testing.T) { + item := templateItem() + + modExpr, err := queryexpr.Parse("alpha") + assert.NoError(t, err) + + err = modExpr.DeleteAttribute(item) + assert.NoError(t, err) + + _, hasKey := item["alpha"] + assert.False(t, hasKey) + }) + + t.Run("placeholder values", func(t *testing.T) { + item := templateItem() + + modExpr, err := queryexpr.Parse(":a") + assert.NoError(t, err) + + modExpr = modExpr.WithNameParams(map[string]string{"a": "alpha"}) + + err = modExpr.DeleteAttribute(item) + assert.NoError(t, err) + + _, hasKey := item["alpha"] + assert.False(t, hasKey) + }) + + t.Run("dot values", func(t *testing.T) { + item := templateItem() + + modExpr, err := queryexpr.Parse("charlie.tree") + assert.NoError(t, err) + + err = modExpr.DeleteAttribute(item) + assert.NoError(t, err) + + _, hasKey := item["charlie"].(*types.AttributeValueMemberM).Value["tree"] + assert.False(t, hasKey) + }) + + t.Run("dot values with placeholders", func(t *testing.T) { + item := templateItem() + + modExpr, err := queryexpr.Parse(":c.tree") + assert.NoError(t, err) + + modExpr = modExpr.WithNameParams(map[string]string{"c": "charlie"}) + + err = modExpr.DeleteAttribute(item) + assert.NoError(t, err) + + _, hasKey := item["charlie"].(*types.AttributeValueMemberM).Value["tree"] + assert.False(t, hasKey) + }) + + //t.Run("dot values with multiple placeholders", func(t *testing.T) { + // item := templateItem() + // + // modExpr, err := queryexpr.Parse(":c.:t") + // assert.NoError(t, err) + // + // modExpr = modExpr.WithNameParams(map[string]string{ + // "c": "charlie", + // "t": "tree", + // }) + // + // err = modExpr.DeleteAttribute(item) + // assert.NoError(t, err) + // + // _, hasKey := item["charlie"].(*types.AttributeValueMemberM).Value["tree"] + // assert.False(t, hasKey) + //}) +} + +func TestQueryExpr_SerializeTo(t *testing.T) { + t.Run("should be able to serialized and deseralize the parsed expression", func(t *testing.T) { + exprStr := `something = $value and :placeholder = "something else" and thirdThing in (1,2,3)` + + bts := new(bytes.Buffer) + + modExpr, err := queryexpr.Parse(exprStr) + assert.NoError(t, err) + + modExpr = modExpr.WithNameParams(map[string]string{ + "placeholder": "some name", + }).WithValueParams(map[string]types.AttributeValue{ + "value": &types.AttributeValueMemberS{Value: "some value"}, + "num": &types.AttributeValueMemberN{Value: "12345"}, + "veryLargeNumber": &types.AttributeValueMemberN{Value: "123456789012345678901234567890"}, + "numberSet": &types.AttributeValueMemberNS{Value: []string{"123", "234", "345"}}, + "bool": &types.AttributeValueMemberBOOL{Value: true}, + "list": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberN{Value: "1"}, + &types.AttributeValueMemberN{Value: "2"}, + &types.AttributeValueMemberN{Value: "3"}, + }}, + "dict": &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "alpha": &types.AttributeValueMemberS{Value: "apple"}, + "bravo": &types.AttributeValueMemberS{Value: "banana"}, + "charlie": &types.AttributeValueMemberS{Value: "cherry"}, + }, + }, + }) + + assert.NoError(t, modExpr.SerializeTo(bts)) + + newExpr, err := queryexpr.DeserializeFrom(bts) + assert.NoError(t, err) + assert.Equal(t, modExpr.String(), newExpr.String()) + + name, hasName := newExpr.NameParam("placeholder") + assert.Equal(t, "some name", name) + assert.True(t, hasName) + + assert.Equal(t, "some value", newExpr.ValueParamOrNil("value").(*types.AttributeValueMemberS).Value) + assert.Equal(t, "12345", newExpr.ValueParamOrNil("num").(*types.AttributeValueMemberN).Value) + assert.Equal(t, "123456789012345678901234567890", newExpr.ValueParamOrNil("veryLargeNumber").(*types.AttributeValueMemberN).Value) + assert.Equal(t, []string{"123", "234", "345"}, newExpr.ValueParamOrNil("numberSet").(*types.AttributeValueMemberNS).Value) + assert.Equal(t, true, newExpr.ValueParamOrNil("bool").(*types.AttributeValueMemberBOOL).Value) + assert.Equal(t, "1", newExpr.ValueParamOrNil("list").(*types.AttributeValueMemberL).Value[0].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "2", newExpr.ValueParamOrNil("list").(*types.AttributeValueMemberL).Value[1].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "3", newExpr.ValueParamOrNil("list").(*types.AttributeValueMemberL).Value[2].(*types.AttributeValueMemberN).Value) + assert.Equal(t, "apple", newExpr.ValueParamOrNil("dict").(*types.AttributeValueMemberM).Value["alpha"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "banana", newExpr.ValueParamOrNil("dict").(*types.AttributeValueMemberM).Value["bravo"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "cherry", newExpr.ValueParamOrNil("dict").(*types.AttributeValueMemberM).Value["charlie"].(*types.AttributeValueMemberS).Value) + }) +} + +func TestQueryExpr_Equals(t *testing.T) { + t.Run("should perform equals correctly", func(t *testing.T) { + exprStr := `something = $value and :placeholder = "something else" and thirdThing in (1,2,3)` + + modExpr, _ := queryexpr.Parse(exprStr) + modExpr = modExpr.WithNameParams(map[string]string{ + "placeholder": "some name", + "another": "name", + "more": "names", + }).WithValueParams(map[string]types.AttributeValue{ + "value": &types.AttributeValueMemberS{Value: "some value"}, + "num": &types.AttributeValueMemberN{Value: "12345"}, + "veryLargeNumber": &types.AttributeValueMemberN{Value: "123456789012345678901234567890"}, + "numberSet": &types.AttributeValueMemberNS{Value: []string{"123", "234", "345"}}, + "bool": &types.AttributeValueMemberBOOL{Value: true}, + "list": &types.AttributeValueMemberL{Value: []types.AttributeValue{ + &types.AttributeValueMemberN{Value: "1"}, + &types.AttributeValueMemberN{Value: "2"}, + &types.AttributeValueMemberN{Value: "3"}, + }}, + "dict": &types.AttributeValueMemberM{ + Value: map[string]types.AttributeValue{ + "alpha": &types.AttributeValueMemberS{Value: "apple"}, + "bravo": &types.AttributeValueMemberS{Value: "banana"}, + "charlie": &types.AttributeValueMemberS{Value: "cherry"}, + }, + }, + }) + + differentExpr, _ := queryexpr.Parse(`abc = :bla`) + differentExpr = modExpr.WithNameParams(map[string]string{ + "fla": "some name", + }).WithValueParams(map[string]types.AttributeValue{ + "value": &types.AttributeValueMemberS{Value: "some value"}, + }) + + bts1, err := modExpr.SerializeToBytes() + assert.NoError(t, err) + + expr2, err := queryexpr.DeserializeFrom(bytes.NewReader(bts1)) + assert.NoError(t, err) + + bts2, err := expr2.SerializeToBytes() + assert.NoError(t, err) + + expr3, err := queryexpr.DeserializeFrom(bytes.NewReader(bts2)) + assert.NoError(t, err) + + _, err = expr3.SerializeToBytes() + assert.NoError(t, err) + + var nilQE *queryexpr.QueryExpr + assert.True(t, nilQE.Equal(nil)) + assert.True(t, modExpr.Equal(expr2)) + assert.True(t, expr2.Equal(expr3)) + assert.True(t, expr3.Equal(modExpr)) + + assert.False(t, nilQE.Equal(differentExpr)) + assert.False(t, modExpr.Equal(differentExpr)) + assert.False(t, expr2.Equal(differentExpr)) + assert.False(t, expr3.Equal(differentExpr)) + + assert.Equal(t, uint64(0), nilQE.HashCode()) + assert.Equal(t, modExpr.HashCode(), expr2.HashCode()) + assert.Equal(t, expr2.HashCode(), expr3.HashCode()) + assert.Equal(t, expr3.HashCode(), modExpr.HashCode()) + + assert.NotEqual(t, differentExpr.HashCode(), nilQE.HashCode()) + assert.NotEqual(t, differentExpr.HashCode(), expr2.HashCode()) + assert.NotEqual(t, differentExpr.HashCode(), expr3.HashCode()) + assert.NotEqual(t, differentExpr.HashCode(), modExpr.HashCode()) + }) } type scanScenario struct { - description string - expression string - expectedFilter string - expectedNames map[string]string - expectedValues map[string]types.AttributeValue + description string + expression string + expectedFilter string + expectedNames map[string]string + expectedValues map[string]types.AttributeValue + placeholderNames map[string]string + placeholderValues map[string]types.AttributeValue } func scanCase(description, expression, expectedFilter string, options ...func(ss *scanScenario)) scanScenario { @@ -458,6 +816,18 @@ func scanCase(description, expression, expectedFilter string, options ...func(ss return ss } +func placeholderNames(placeholderNames map[string]string) func(ss *scanScenario) { + return func(ss *scanScenario) { + ss.placeholderNames = placeholderNames + } +} + +func placeholderValues(placeholderValues map[string]types.AttributeValue) func(ss *scanScenario) { + return func(ss *scanScenario) { + ss.placeholderValues = placeholderValues + } +} + func exprName(idx int, name string) func(ss *scanScenario) { return func(ss *scanScenario) { ss.expectedNames[fmt.Sprintf("#%d", idx)] = name diff --git a/internal/dynamo-browse/models/queryexpr/fncall.go b/internal/dynamo-browse/models/queryexpr/fncall.go index a9034d0..358071e 100644 --- a/internal/dynamo-browse/models/queryexpr/fncall.go +++ b/internal/dynamo-browse/models/queryexpr/fncall.go @@ -10,8 +10,8 @@ import ( "strings" ) -func (a *astFunctionCall) evalToIR(info *models.TableInfo) (irAtom, error) { - callerIr, err := a.Caller.evalToIR(info) +func (a *astFunctionCall) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + callerIr, err := a.Caller.evalToIR(ctx, info) if err != nil { return nil, err } @@ -24,7 +24,7 @@ func (a *astFunctionCall) evalToIR(info *models.TableInfo) (irAtom, error) { return nil, OperandNotANameError("") } - irNodes, err := sliceutils.MapWithError(a.Args, func(x *astExpr) (irAtom, error) { return x.evalToIR(info) }) + irNodes, err := sliceutils.MapWithError(a.Args, func(x *astExpr) (irAtom, error) { return x.evalToIR(ctx, info) }) if err != nil { return nil, err } @@ -53,9 +53,9 @@ func (a *astFunctionCall) evalToIR(info *models.TableInfo) (irAtom, error) { return nil, UnrecognisedFunctionError{Name: nameIr.keyName()} } -func (a *astFunctionCall) evalItem(item models.Item) (types.AttributeValue, error) { +func (a *astFunctionCall) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { if !a.IsCall { - return a.Caller.evalItem(item) + return a.Caller.evalItem(ctx, item) } name, isName := a.Caller.unqualifiedName() @@ -68,7 +68,7 @@ func (a *astFunctionCall) evalItem(item models.Item) (types.AttributeValue, erro } args, err := sliceutils.MapWithError(a.Args, func(a *astExpr) (types.AttributeValue, error) { - return a.evalItem(item) + return a.evalItem(ctx, item) }) if err != nil { return nil, err @@ -77,6 +77,30 @@ func (a *astFunctionCall) evalItem(item models.Item) (types.AttributeValue, erro return fn(context.Background(), args) } +func (a *astFunctionCall) canModifyItem(ctx *evalContext, item models.Item) bool { + // TODO: Should a function vall return an item? + if a.IsCall { + return false + } + return a.Caller.canModifyItem(ctx, item) +} + +func (a *astFunctionCall) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + // TODO: Should a function vall return an item? + if a.IsCall { + return PathNotSettableError{} + } + return a.Caller.setEvalItem(ctx, item, value) +} + +func (a *astFunctionCall) deleteAttribute(ctx *evalContext, item models.Item) error { + // TODO: Should a function vall return an item? + if a.IsCall { + return PathNotSettableError{} + } + return a.Caller.deleteAttribute(ctx, item) +} + func (a *astFunctionCall) String() string { var sb strings.Builder diff --git a/internal/dynamo-browse/models/queryexpr/in.go b/internal/dynamo-browse/models/queryexpr/in.go index 6cc3f51..6a169d3 100644 --- a/internal/dynamo-browse/models/queryexpr/in.go +++ b/internal/dynamo-browse/models/queryexpr/in.go @@ -12,8 +12,8 @@ import ( "strings" ) -func (a *astIn) evalToIR(info *models.TableInfo) (irAtom, error) { - leftIR, err := a.Ref.evalToIR(info) +func (a *astIn) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + leftIR, err := a.Ref.evalToIR(ctx, info) if err != nil { return nil, err } @@ -32,7 +32,7 @@ func (a *astIn) evalToIR(info *models.TableInfo) (irAtom, error) { oprValues := make([]oprIRAtom, len(a.Operand)) for i, o := range a.Operand { - v, err := o.evalToIR(info) + v, err := o.evalToIR(ctx, info) if err != nil { return nil, err } @@ -59,7 +59,7 @@ func (a *astIn) evalToIR(info *models.TableInfo) (irAtom, error) { ir = irIn{name: nameIR, values: oprValues} case a.SingleOperand != nil: - oprs, err := a.SingleOperand.evalToIR(info) + oprs, err := a.SingleOperand.evalToIR(ctx, info) if err != nil { return nil, err } @@ -96,8 +96,8 @@ func (a *astIn) evalToIR(info *models.TableInfo) (irAtom, error) { return ir, nil } -func (a *astIn) evalItem(item models.Item) (types.AttributeValue, error) { - val, err := a.Ref.evalItem(item) +func (a *astIn) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + val, err := a.Ref.evalItem(ctx, item) if err != nil { return nil, err } @@ -108,7 +108,7 @@ func (a *astIn) evalItem(item models.Item) (types.AttributeValue, error) { switch { case len(a.Operand) > 0: for _, opr := range a.Operand { - evalOp, err := opr.evalItem(item) + evalOp, err := opr.evalItem(ctx, item) if err != nil { return nil, err } @@ -121,7 +121,7 @@ func (a *astIn) evalItem(item models.Item) (types.AttributeValue, error) { } return &types.AttributeValueMemberBOOL{Value: false}, nil case a.SingleOperand != nil: - evalOp, err := a.SingleOperand.evalItem(item) + evalOp, err := a.SingleOperand.evalItem(ctx, item) if err != nil { return nil, err } @@ -194,6 +194,28 @@ func (a *astIn) evalItem(item models.Item) (types.AttributeValue, error) { return nil, errors.New("internal error: unhandled 'in' case") } +func (a *astIn) canModifyItem(ctx *evalContext, item models.Item) bool { + if len(a.Operand) != 0 || a.SingleOperand != nil { + return false + } + return a.Ref.canModifyItem(ctx, item) +} + +func (a *astIn) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if len(a.Operand) != 0 || a.SingleOperand != nil { + return PathNotSettableError{} + } + return a.Ref.setEvalItem(ctx, item, value) +} + +func (a *astIn) deleteAttribute(ctx *evalContext, item models.Item) error { + if len(a.Operand) != 0 || a.SingleOperand != nil { + return PathNotSettableError{} + } + return a.Ref.deleteAttribute(ctx, item) + +} + func (a *astIn) String() string { if len(a.Operand) == 0 && a.SingleOperand == nil { return a.Ref.String() diff --git a/internal/dynamo-browse/models/queryexpr/is.go b/internal/dynamo-browse/models/queryexpr/is.go index b4f0ba5..50daf6b 100644 --- a/internal/dynamo-browse/models/queryexpr/is.go +++ b/internal/dynamo-browse/models/queryexpr/is.go @@ -59,8 +59,8 @@ var validIsTypeNames = map[string]isTypeInfo{ }, } -func (a *astIsOp) evalToIR(info *models.TableInfo) (irAtom, error) { - leftIR, err := a.Ref.evalToIR(info) +func (a *astIsOp) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + leftIR, err := a.Ref.evalToIR(ctx, info) if err != nil { return nil, err } @@ -74,7 +74,7 @@ func (a *astIsOp) evalToIR(info *models.TableInfo) (irAtom, error) { return nil, OperandNotANameError(a.Ref.String()) } - rightIR, err := a.Value.evalToIR(info) + rightIR, err := a.Value.evalToIR(ctx, info) if err != nil { return nil, err } @@ -104,8 +104,8 @@ func (a *astIsOp) evalToIR(info *models.TableInfo) (irAtom, error) { return ir, nil } -func (a *astIsOp) evalItem(item models.Item) (types.AttributeValue, error) { - ref, err := a.Ref.evalItem(item) +func (a *astIsOp) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + ref, err := a.Ref.evalItem(ctx, item) if err != nil { return nil, err } @@ -114,7 +114,7 @@ func (a *astIsOp) evalItem(item models.Item) (types.AttributeValue, error) { return ref, nil } - expTypeVal, err := a.Value.evalItem(item) + expTypeVal, err := a.Value.evalItem(ctx, item) if err != nil { return nil, err } @@ -140,6 +140,27 @@ func (a *astIsOp) evalItem(item models.Item) (types.AttributeValue, error) { return &types.AttributeValueMemberBOOL{Value: resultOfIs}, nil } +func (a *astIsOp) canModifyItem(ctx *evalContext, item models.Item) bool { + if a.Value != nil { + return false + } + return a.Ref.canModifyItem(ctx, item) +} + +func (a *astIsOp) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if a.Value != nil { + return PathNotSettableError{} + } + return a.Ref.setEvalItem(ctx, item, value) +} + +func (a *astIsOp) deleteAttribute(ctx *evalContext, item models.Item) error { + if a.Value != nil { + return PathNotSettableError{} + } + return a.Ref.deleteAttribute(ctx, item) +} + func (a *astIsOp) String() string { var sb strings.Builder diff --git a/internal/dynamo-browse/models/queryexpr/placeholder.go b/internal/dynamo-browse/models/queryexpr/placeholder.go new file mode 100644 index 0000000..ec94a83 --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/placeholder.go @@ -0,0 +1,109 @@ +package queryexpr + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/pkg/errors" +) + +const ( + valuePlaceholderPrefix = '$' + namePlaceholderPrefix = ':' +) + +func (p *astPlaceholder) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + placeholderType := p.Placeholder[0] + placeholder := p.Placeholder[1:] + + if placeholderType == valuePlaceholderPrefix { + val, hasVal := ctx.lookupValue(placeholder) + if !hasVal { + return nil, MissingPlaceholderError{Placeholder: p.Placeholder} + } + + return irValue{value: val}, nil + } else if placeholderType == namePlaceholderPrefix { + name, hasName := ctx.lookupName(placeholder) + if !hasName { + return nil, MissingPlaceholderError{Placeholder: p.Placeholder} + } + + return irNamePath{name, nil}, nil + } + + return nil, errors.New("unrecognised placeholder") +} + +func (p *astPlaceholder) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + placeholderType := p.Placeholder[0] + placeholder := p.Placeholder[1:] + + if placeholderType == valuePlaceholderPrefix { + val, hasVal := ctx.lookupValue(placeholder) + if !hasVal { + return nil, MissingPlaceholderError{Placeholder: p.Placeholder} + } + return val, nil + } else if placeholderType == namePlaceholderPrefix { + name, hasName := ctx.lookupName(placeholder) + if !hasName { + return nil, MissingPlaceholderError{Placeholder: p.Placeholder} + } + + res, hasV := item[name] + if !hasV { + return nil, nil + } + + return res, nil + } + + return nil, errors.New("unrecognised placeholder") +} + +func (p *astPlaceholder) canModifyItem(ctx *evalContext, item models.Item) bool { + placeholderType := p.Placeholder[0] + return placeholderType == namePlaceholderPrefix +} + +func (p *astPlaceholder) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + placeholderType := p.Placeholder[0] + placeholder := p.Placeholder[1:] + + if placeholderType == valuePlaceholderPrefix { + return PathNotSettableError{} + } else if placeholderType == namePlaceholderPrefix { + name, hasName := ctx.lookupName(placeholder) + if !hasName { + return MissingPlaceholderError{Placeholder: p.Placeholder} + } + + item[name] = value + return nil + } + + return errors.New("unrecognised placeholder") +} + +func (p *astPlaceholder) deleteAttribute(ctx *evalContext, item models.Item) error { + placeholderType := p.Placeholder[0] + placeholder := p.Placeholder[1:] + + if placeholderType == valuePlaceholderPrefix { + return PathNotSettableError{} + } else if placeholderType == namePlaceholderPrefix { + name, hasName := ctx.lookupName(placeholder) + if !hasName { + return MissingPlaceholderError{Placeholder: p.Placeholder} + } + + delete(item, name) + return nil + } + + return errors.New("unrecognised placeholder") +} + +func (p *astPlaceholder) String() string { + return p.Placeholder +} diff --git a/internal/dynamo-browse/models/queryexpr/subref.go b/internal/dynamo-browse/models/queryexpr/subref.go new file mode 100644 index 0000000..01c26ca --- /dev/null +++ b/internal/dynamo-browse/models/queryexpr/subref.go @@ -0,0 +1,118 @@ +package queryexpr + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models" + "strings" +) + +func (r *astSubRef) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { + refIR, err := r.Ref.evalToIR(ctx, info) + if err != nil { + return nil, err + } + if len(r.Quals) == 0 { + return refIR, nil + } + + // This node has subrefs + namePath, isNamePath := refIR.(irNamePath) + if !isNamePath { + return nil, OperandNotANameError(r.String()) + } + + quals := make([]string, 0) + for _, sr := range r.Quals { + quals = append(quals, sr) + } + return irNamePath{name: namePath.name, quals: quals}, nil +} + +func (r *astSubRef) evalItem(ctx *evalContext, item models.Item) (types.AttributeValue, error) { + res, err := r.Ref.evalItem(ctx, item) + if err != nil { + return nil, err + } + + for i, qualName := range r.Quals { + var hasV bool + + mapRes, isMapRes := res.(*types.AttributeValueMemberM) + if !isMapRes { + return nil, ValueNotAMapError(append([]string{r.Ref.String()}, r.Quals[:i+1]...)) + } + + res, hasV = mapRes.Value[qualName] + if !hasV { + return nil, nil + } + } + + return res, nil +} + +func (r *astSubRef) canModifyItem(ctx *evalContext, item models.Item) bool { + return r.Ref.canModifyItem(ctx, item) +} + +func (r *astSubRef) setEvalItem(ctx *evalContext, item models.Item, value types.AttributeValue) error { + if len(r.Quals) == 0 { + return r.Ref.setEvalItem(ctx, item, value) + } + + parentItem, err := r.Ref.evalItem(ctx, item) + if err != nil { + return err + } + + for i, key := range r.Quals { + mapItem, isMapItem := parentItem.(*types.AttributeValueMemberM) + if !isMapItem { + return PathNotSettableError{} + } + + if isLast := i == len(r.Quals)-1; isLast { + mapItem.Value[key] = value + } else { + parentItem = mapItem.Value[key] + } + } + return nil +} + +func (r *astSubRef) deleteAttribute(ctx *evalContext, item models.Item) error { + if len(r.Quals) == 0 { + return r.Ref.deleteAttribute(ctx, item) + } + + parentItem, err := r.Ref.evalItem(ctx, item) + if err != nil { + return err + } + + for i, key := range r.Quals { + mapItem, isMapItem := parentItem.(*types.AttributeValueMemberM) + if !isMapItem { + return PathNotSettableError{} + } + + if isLast := i == len(r.Quals)-1; isLast { + delete(mapItem.Value, key) + } else { + parentItem = mapItem.Value[key] + } + } + return nil +} + +func (r *astSubRef) String() string { + var sb strings.Builder + + sb.WriteString(r.Ref.String()) + for _, q := range r.Quals { + sb.WriteRune('.') + sb.WriteString(q) + } + + return sb.String() +} diff --git a/internal/dynamo-browse/models/queryexpr/values.go b/internal/dynamo-browse/models/queryexpr/values.go index 7ef60da..387f29d 100644 --- a/internal/dynamo-browse/models/queryexpr/values.go +++ b/internal/dynamo-browse/models/queryexpr/values.go @@ -9,7 +9,7 @@ import ( "github.com/pkg/errors" ) -func (a *astLiteralValue) evalToIR(info *models.TableInfo) (irAtom, error) { +func (a *astLiteralValue) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) { v, err := a.goValue() if err != nil { return nil, err diff --git a/internal/dynamo-browse/models/serialisable/viewsnapshot.go b/internal/dynamo-browse/models/serialisable/viewsnapshot.go index 78a7191..a2ea038 100644 --- a/internal/dynamo-browse/models/serialisable/viewsnapshot.go +++ b/internal/dynamo-browse/models/serialisable/viewsnapshot.go @@ -1,6 +1,8 @@ package serialisable import ( + "bytes" + "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" "time" ) @@ -14,6 +16,32 @@ type ViewSnapshot struct { type ViewSnapshotDetails struct { TableName string - Query string + Query []byte + QueryHash uint64 Filter string } + +func (d ViewSnapshotDetails) Equals(other ViewSnapshotDetails, compareHashesOnly bool) bool { + return d.TableName == other.TableName && + d.Filter == other.Filter && + d.compareQueries(other, compareHashesOnly) +} + +func (d ViewSnapshotDetails) compareQueries(other ViewSnapshotDetails, compareHashesOnly bool) bool { + if d.QueryHash != other.QueryHash { + return false + } + if compareHashesOnly { + return true + } + + expr1, err := queryexpr.DeserializeFrom(bytes.NewReader(d.Query)) + if err != nil { + return false + } + expr2, err := queryexpr.DeserializeFrom(bytes.NewReader(other.Query)) + if err != nil { + return false + } + return expr1.Equal(expr2) +} diff --git a/internal/dynamo-browse/providers/settingstore/settingstore.go b/internal/dynamo-browse/providers/settingstore/settingstore.go index f938be5..33c7a27 100644 --- a/internal/dynamo-browse/providers/settingstore/settingstore.go +++ b/internal/dynamo-browse/providers/settingstore/settingstore.go @@ -4,7 +4,11 @@ import ( "github.com/asdine/storm" "github.com/lmika/audax/internal/common/workspaces" "github.com/pkg/errors" + "io/fs" "log" + "os" + "path/filepath" + "strings" ) const settingBucket = "Settings" @@ -12,8 +16,10 @@ const settingBucket = "Settings" const ( keyTableReadOnly = "ro" keyTableDefaultLimit = "default_limit" + keyScriptLookupPath = "script_lookup_path" - defaultsDefaultLimit = 1000 + defaultsDefaultLimit = 1000 + defaultScriptLookupPaths = "${HOME}/.config/audax/dynamo-browse/scripts" ) type SettingStore struct { @@ -26,6 +32,48 @@ func New(ws *workspaces.Workspace) *SettingStore { } } +func (c *SettingStore) SetScriptLookupPaths(value string) error { + return c.ws.Set(settingBucket, keyTableReadOnly, value) +} + +func (c *SettingStore) ScriptLookupFS() ([]fs.FS, error) { + paths, err := c.getStringValue(keyScriptLookupPath, defaultScriptLookupPaths) + if err != nil { + return nil, err + } + + if paths == "" { + return nil, nil + } + + fs := make([]fs.FS, 0, len(paths)) + for _, path := range strings.Split(paths, string(os.PathListSeparator)) { + expandedPath := os.ExpandEnv(path) + + var absPath string + if filepath.IsAbs(path) { + absPath = expandedPath + } else { + absPath, err = filepath.Abs(expandedPath) + if err != nil { + log.Printf("warn: cannot include script lookup path '%v': %v", expandedPath, err) + continue + } + } + + if stat, err := os.Stat(absPath); err != nil { + log.Printf("warn: cannot stat script lookup path '%v': %v", expandedPath, err) + } else if stat.IsDir() { + log.Printf("warn: script lookup path '%v' is not a directory", expandedPath) + } + + log.Printf("adding script lookup path: %v", absPath) + fs = append(fs, os.DirFS(absPath)) + } + + return fs, nil +} + func (c *SettingStore) IsReadOnly() (b bool, err error) { if err := c.ws.Get(settingBucket, keyTableReadOnly, &b); err != nil { if errors.Is(err, storm.ErrNotFound) { @@ -54,3 +102,14 @@ func (c *SettingStore) DefaultLimit() (limit int) { func (c *SettingStore) SetDefaultLimit(limit int) error { return errors.Wrapf(c.ws.Set(settingBucket, keyTableDefaultLimit, &limit), "cannot set default limit to %v", limit) } + +func (c *SettingStore) getStringValue(key string, def string) (string, error) { + var val string + if err := c.ws.Get(settingBucket, keyTableReadOnly, &val); err != nil { + if errors.Is(err, storm.ErrNotFound) { + return def, nil + } + return "", err + } + return val, nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/iface.go b/internal/dynamo-browse/services/scriptmanager/iface.go new file mode 100644 index 0000000..9ab84da --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/iface.go @@ -0,0 +1,36 @@ +package scriptmanager + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/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 { + NamePlaceholders map[string]string + ValuePlaceholders map[string]types.AttributeValue +} diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go new file mode 100644 index 0000000..8332c3e --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/mocks/SessionService.go @@ -0,0 +1,193 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/lmika/audax/internal/dynamo-browse/models" + mock "github.com/stretchr/testify/mock" + + scriptmanager "github.com/lmika/audax/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 + 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) + } + } + + var r1 error + 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +type mockConstructorTestingTNewSessionService interface { + mock.TestingT + Cleanup(func()) +} + +// NewSessionService creates a new instance of SessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewSessionService(t mockConstructorTestingTNewSessionService) *SessionService { + mock := &SessionService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go new file mode 100644 index 0000000..8a943d4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/mocks/UIService.go @@ -0,0 +1,106 @@ +// Code generated by mockery v2.16.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 +} + +// 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 +} + +type mockConstructorTestingTNewUIService interface { + mock.TestingT + Cleanup(func()) +} + +// NewUIService creates a new instance of UIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewUIService(t mockConstructorTestingTNewUIService) *UIService { + mock := &UIService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/dynamo-browse/services/scriptmanager/modext.go b/internal/dynamo-browse/services/scriptmanager/modext.go new file mode 100644 index 0000000..7977f21 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modext.go @@ -0,0 +1,67 @@ +package scriptmanager + +import ( + "context" + "github.com/cloudcmds/tamarin/arg" + "github.com/cloudcmds/tamarin/object" + "github.com/cloudcmds/tamarin/scope" + "github.com/pkg/errors" +) + +type extModule struct { + scriptPlugin *ScriptPlugin +} + +func (m *extModule) register(scp *scope.Scope) { + modScope := scope.New(scope.Opts{}) + mod := object.NewModule("ext", modScope) + + modScope.AddBuiltins([]*object.Builtin{ + object.NewBuiltin("command", m.command, mod), + }) + + scp.Declare("ext", mod, true) +} + +func (m *extModule) command(ctx context.Context, args ...object.Object) object.Object { + if err := arg.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) + } + + ctx = ctxWithOptions(ctx, m.scriptPlugin.scriptService.options) + + res := callFn(ctx, fnRes.Scope(), fnRes, objArgs) + 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 +} diff --git a/internal/dynamo-browse/services/scriptmanager/modos.go b/internal/dynamo-browse/services/scriptmanager/modos.go new file mode 100644 index 0000000..4a1d1c5 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modos.go @@ -0,0 +1,47 @@ +package scriptmanager + +import ( + "context" + "github.com/cloudcmds/tamarin/arg" + "github.com/cloudcmds/tamarin/object" + "github.com/cloudcmds/tamarin/scope" + "os/exec" +) + +type osModule struct { +} + +func (om *osModule) exec(ctx context.Context, args ...object.Object) object.Object { + if err := arg.Require("os.exec", 1, args); err != nil { + return err + } + + cmdExec, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + opts := optionFromCtx(ctx) + if !opts.Permissions.AllowShellCommands { + return object.NewErrResult(object.Errorf("permission error: no permission to shell out")) + } + + cmd := exec.Command(opts.OSExecShell, "-c", cmdExec) + out, err := cmd.Output() + if err != nil { + return object.NewErrResult(object.NewError(err)) + } + + return object.NewOkResult(object.NewString(string(out))) +} + +func (om *osModule) register(scp *scope.Scope) { + modScope := scope.New(scope.Opts{}) + mod := object.NewModule("os", modScope) + + modScope.AddBuiltins([]*object.Builtin{ + object.NewBuiltin("exec", om.exec, mod), + }) + + scp.Declare("os", mod, true) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modos_test.go b/internal/dynamo-browse/services/scriptmanager/modos_test.go new file mode 100644 index 0000000..03f36ed --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modos_test.go @@ -0,0 +1,110 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +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, "false") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "hello world\n") + + testFS := testScriptFile(t, "test.tm", ` + res := os.exec('echo "hello world"') + ui.print(res.is_err()) + ui.print(res.unwrap()) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetDefaultOptions(scriptmanager.Options{ + OSExecShell: "/bin/bash", + Permissions: scriptmanager.Permissions{ + AllowShellCommands: true, + }, + }) + 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 refuse to execute command if do not have permissions", func(t *testing.T) { + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "true") + + testFS := testScriptFile(t, "test.tm", ` + res := os.exec('echo "hello world"') + ui.print(res.is_err()) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetDefaultOptions(scriptmanager.Options{ + OSExecShell: "/bin/bash", + Permissions: scriptmanager.Permissions{ + AllowShellCommands: false, + }, + }) + 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 be able to change permissions which will affect plugins", func(t *testing.T) { + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Loaded the plugin\n") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "true") + + testFS := testScriptFile(t, "test.tm", ` + ext.command("mycommand", func() { + ui.print(os.exec('echo "this cannot run"').is_err()) + }) + + ui.print(os.exec('echo "Loaded the plugin"').unwrap()) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetDefaultOptions(scriptmanager.Options{ + OSExecShell: "/bin/bash", + Permissions: scriptmanager.Permissions{ + AllowShellCommands: true, + }, + }) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + ctx := context.Background() + _, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + + srv.SetDefaultOptions(scriptmanager.Options{ + OSExecShell: "/bin/bash", + Permissions: scriptmanager.Permissions{ + AllowShellCommands: false, + }, + }) + + errChan := make(chan error) + assert.NoError(t, srv.LookupCommand("mycommand").Invoke(ctx, []string{}, errChan)) + assert.NoError(t, waitForErr(t, errChan)) + + mockedUIService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modsession.go b/internal/dynamo-browse/services/scriptmanager/modsession.go new file mode 100644 index 0000000..004d3cb --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modsession.go @@ -0,0 +1,121 @@ +package scriptmanager + +import ( + "context" + "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/cloudcmds/tamarin/arg" + "github.com/cloudcmds/tamarin/object" + "github.com/cloudcmds/tamarin/scope" + "github.com/pkg/errors" +) + +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 + } + + // 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.NewErrResult(object.NewError(err)) + } + return object.NewOkResult(&resultSetProxy{resultSet: resp}) +} + +func (um *sessionModule) resultSet(ctx context.Context, args ...object.Object) object.Object { + if err := arg.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 := arg.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 := arg.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) register(scp *scope.Scope) { + modScope := scope.New(scope.Opts{}) + mod := object.NewModule("session", modScope) + + modScope.AddBuiltins([]*object.Builtin{ + object.NewBuiltin("query", um.query, mod), + object.NewBuiltin("result_set", um.resultSet, mod), + object.NewBuiltin("selected_item", um.selectedItem, mod), + object.NewBuiltin("set_result_set", um.setResultSet, mod), + }) + + scp.Declare("session", mod, true) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modsession_test.go b/internal/dynamo-browse/services/scriptmanager/modsession_test.go new file mode 100644 index 0000000..3e3bcfb --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modsession_test.go @@ -0,0 +1,292 @@ +package scriptmanager_test + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +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").unwrap() + 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) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "true") + mockedUIService.EXPECT().PrintMessage(mock.Anything, "err(\"bang\")") + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr") + ui.print(res.is_err()) + ui.print(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.is_err()) + `) + + 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.is_err()) + `) + + 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() { }, + }, + }) + assert(res.is_err()) + `) + + 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").unwrap() + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modui.go b/internal/dynamo-browse/services/scriptmanager/modui.go new file mode 100644 index 0000000..75cdd12 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modui.go @@ -0,0 +1,60 @@ +package scriptmanager + +import ( + "context" + "github.com/cloudcmds/tamarin/arg" + "github.com/cloudcmds/tamarin/object" + "github.com/cloudcmds/tamarin/scope" + "strings" +) + +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 { + 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 := arg.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.NewError(ctx.Err()) + } + case <-ctx.Done(): + return object.NewError(ctx.Err()) + } +} + +func (um *uiModule) register(scp *scope.Scope) { + modScope := scope.New(scope.Opts{}) + mod := object.NewModule("ui", modScope) + + modScope.AddBuiltins([]*object.Builtin{ + object.NewBuiltin("print", um.print, mod), + object.NewBuiltin("prompt", um.prompt, mod), + }) + + scp.Declare("ui", mod, true) +} diff --git a/internal/dynamo-browse/services/scriptmanager/modui_test.go b/internal/dynamo-browse/services/scriptmanager/modui_test.go new file mode 100644 index 0000000..10a2a1c --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/modui_test.go @@ -0,0 +1,98 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/audax/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 error 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") + `) + + promptChan := make(chan string) + close(promptChan) + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + 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.Error(t, err) + + mockedUIService.AssertNotCalled(t, "Prompt", "after") + mockedUIService.AssertExpectations(t) + }) + + t.Run("should return error if context was cancelled", func(t *testing.T) { + testFS := testScriptFile(t, "test.tm", ` + ui.print("Hello, world") + var name = ui.prompt("What is your name? ") + ui.print("After") + `) + + promptChan := make(chan string) + ctx, cancelFn := context.WithCancel(context.Background()) + defer cancelFn() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world") + mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Run(func(ctx context.Context, msg string) { + cancelFn() + }).Return(promptChan) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.Error(t, err) + + mockedUIService.AssertNotCalled(t, "Prompt", "after") + mockedUIService.AssertExpectations(t) + }) +} diff --git a/internal/dynamo-browse/services/scriptmanager/opts.go b/internal/dynamo-browse/services/scriptmanager/opts.go new file mode 100644 index 0000000..d40600c --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/opts.go @@ -0,0 +1,45 @@ +package scriptmanager + +import ( + "context" + "os" +) + +type Options struct { + // OSExecShell is the shell to use for calls to 'os.exec'. If not defined, + // it will use the value of the SHELL environment variable, otherwise it will + // default to '/bin/bash' + OSExecShell string + + // Permissions are the permissions the script can execute in + Permissions Permissions +} + +func (opts Options) configuredShell() string { + if opts.OSExecShell != "" { + return opts.OSExecShell + } + if shell, hasShell := os.LookupEnv("SHELL"); hasShell { + return shell + } + return "/bin/bash" +} + +// Permissions control the set of permissions of a script +type Permissions struct { + // AllowShellCommands determines whether or not a script can execute shell commands. + AllowShellCommands bool +} + +type optionCtxKeyType struct{} + +var optionCtxKey = optionCtxKeyType{} + +func optionFromCtx(ctx context.Context) Options { + perms, _ := ctx.Value(optionCtxKey).(Options) + return perms +} + +func ctxWithOptions(ctx context.Context, perms Options) context.Context { + return context.WithValue(ctx, optionCtxKey, perms) +} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go new file mode 100644 index 0000000..a8a49a4 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy.go @@ -0,0 +1,240 @@ +package scriptmanager + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/cloudcmds/tamarin/arg" + "github.com/cloudcmds/tamarin/object" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" + "github.com/pkg/errors" + "strconv" +) + +type resultSetProxy struct { + resultSet *models.ResultSet +} + +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 "length": + return object.NewInt(int64(len(r.resultSet.Items()))), true + } + + return nil, false +} + +type itemProxy struct { + resultSetProxy *resultSetProxy + itemIndex int + item models.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 := arg.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)) + } + + // TODO + switch v := av.(type) { + case *types.AttributeValueMemberS: + return object.NewString(v.Value) + case *types.AttributeValueMemberN: + // TODO: better + f, err := strconv.ParseFloat(v.Value, 64) + if err != nil { + return object.NewError(errors.Errorf("value error: invalid N value: %v", v.Value)) + } + return object.NewFloat(f) + } + return object.NewError(errors.New("TODO")) +} + +func (i *itemProxy) setValue(ctx context.Context, args ...object.Object) object.Object { + if objErr := arg.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) + } + + // TODO + newValue := args[1] + switch v := newValue.(type) { + case *object.String: + if err := path.SetEvalItem(i.item, &types.AttributeValueMemberS{Value: v.Value()}); err != nil { + return object.NewError(err) + } + default: + return object.Errorf("type error: unsupported value type (got %v)", newValue.Type()) + } + + i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true) + return nil +} + +func (i *itemProxy) deleteAttr(ctx context.Context, args ...object.Object) object.Object { + if objErr := arg.Require("item.delete_attr", 1, args); objErr != nil { + return objErr + } + + str, objErr := object.AsString(args[0]) + if objErr != nil { + return objErr + } + + modExpr, err := queryexpr.Parse(str) + if err != nil { + return object.Errorf("arg error: invalid path expression: %v", err) + } + if err := modExpr.DeleteAttribute(i.item); err != nil { + return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err)) + } + + i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true) + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go new file mode 100644 index 0000000..63f6502 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/resultsetproxy_test.go @@ -0,0 +1,135 @@ +package scriptmanager_test + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func TestResultSetProxy(t *testing.T) { + t.Run("should property return properties of a resultset and item", 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) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr").unwrap() + + // Test properties of the result set + 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_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.True(t, rs.IsDirty(0)) + return true + })) + + mockedUIService := mocks.NewUIService(t) + + testFS := testScriptFile(t, "test.tm", ` + res := session.query("some expr").unwrap() + res[0].set_attr("pk", "bla-di-bla") + 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").unwrap() + res[0].delete_attr("deleteMe") + session.set_result_set(res) + `) + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + Session: mockedSessionService, + }) + + ctx := context.Background() + err := <-srv.RunAdHocScript(ctx, "test.tm") + assert.NoError(t, err) + + mockedUIService.AssertExpectations(t) + mockedSessionService.AssertExpectations(t) + }) + +} diff --git a/internal/dynamo-browse/services/scriptmanager/scrsched.go b/internal/dynamo-browse/services/scriptmanager/scrsched.go new file mode 100644 index 0000000..e04ebdf --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/scrsched.go @@ -0,0 +1,52 @@ +package scriptmanager + +import ( + "context" + "github.com/pkg/errors" +) + +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 + default: + return errors.New("a script is already running") + } +} + +type scriptJob struct { + ctx context.Context + job func(ctx context.Context) +} diff --git a/internal/dynamo-browse/services/scriptmanager/service.go b/internal/dynamo-browse/services/scriptmanager/service.go new file mode 100644 index 0000000..055a4de --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/service.go @@ -0,0 +1,185 @@ +package scriptmanager + +import ( + "context" + "github.com/cloudcmds/tamarin/exec" + "github.com/cloudcmds/tamarin/scope" + "github.com/pkg/errors" + "io/fs" + "os" + "path/filepath" +) + +type Service struct { + lookupPaths []fs.FS + ifaces Ifaces + options Options + 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) SetDefaultOptions(options Options) { + s.options = options +} + +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) + if err != nil { + errChan <- errors.Wrapf(err, "cannot load script file %v", filename) + return + } + + scp := scope.New(scope.Opts{Parent: s.parentScope()}) + + ctx = ctxWithOptions(ctx, s.options) + + if _, err = exec.Execute(ctx, exec.Opts{ + Input: string(code), + File: filename, + Scope: scp, + }); 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) + if err != nil { + resChan <- loadedScriptResult{err: errors.Wrapf(err, "cannot load script file %v", filename)} + return + } + + newPlugin := &ScriptPlugin{ + name: filepath.Base(filename), + scriptService: s, + } + + scp := scope.New(scope.Opts{Parent: s.parentScope()}) + + (&extModule{scriptPlugin: newPlugin}).register(scp) + + ctx = ctxWithOptions(ctx, s.options) + + if _, err = exec.Execute(ctx, exec.Opts{ + Input: string(code), + File: filename, + Scope: scp, + }); err != nil { + resChan <- loadedScriptResult{err: errors.Wrapf(err, "script %v", filename)} + return + } + + resChan <- loadedScriptResult{scriptPlugin: newPlugin} +} + +func (s *Service) readScript(filename string) ([]byte, error) { + for _, currFS := range s.lookupPaths { + stat, err := fs.Stat(currFS, filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } else { + return nil, err + } + } else if stat.IsDir() { + continue + } + + code, err := fs.ReadFile(currFS, filename) + if err == nil { + return code, nil + } else { + return nil, err + } + } + + return nil, 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) parentScope() *scope.Scope { + scp := scope.New(scope.Opts{}) + (&uiModule{uiService: s.ifaces.UI}).register(scp) + (&sessionModule{sessionService: s.ifaces.Session}).register(scp) + (&osModule{}).register(scp) + return scp +} diff --git a/internal/dynamo-browse/services/scriptmanager/service_test.go b/internal/dynamo-browse/services/scriptmanager/service_test.go new file mode 100644 index 0000000..91ab574 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/service_test.go @@ -0,0 +1,150 @@ +package scriptmanager_test + +import ( + "context" + "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager" + "github.com/lmika/audax/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.tm", plugin.Name()) + + cmd := srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan := make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + mockedUIService.AssertExpectations(t) + }) + + t.Run("reloading a script with the same name should remove the old one", func(t *testing.T) { + testFS := fstest.MapFS{ + "test.tm": &fstest.MapFile{ + Data: []byte(` + ext.command("somewhere", func(a) { + ui.print("Hello, " + a) + }) + `), + }, + } + + ctx := context.Background() + + mockedUIService := mocks.NewUIService(t) + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone").Once() + mockedUIService.EXPECT().PrintMessage(mock.Anything, "Goodbye, someone").Once() + + srv := scriptmanager.New(scriptmanager.WithFS(testFS)) + srv.SetIFaces(scriptmanager.Ifaces{ + UI: mockedUIService, + }) + + // Execute the old script + _, err := srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + + cmd := srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan := make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + // Change the script and reload + testFS["test.tm"] = &fstest.MapFile{ + Data: []byte(` + ext.command("somewhere", func(a) { + ui.print("Goodbye, " + a) + }) + `), + } + + _, err = srv.LoadScript(ctx, "test.tm") + assert.NoError(t, err) + + cmd = srv.LookupCommand("somewhere") + assert.NotNil(t, cmd) + + errChan = make(chan error) + err = cmd.Invoke(ctx, []string{"someone"}, errChan) + assert.NoError(t, err) + assert.NoError(t, waitForErr(t, errChan)) + + mockedUIService.AssertExpectations(t) + }) +} + +func testScriptFile(t *testing.T, filename, code string) fs.FS { + t.Helper() + + testFs := fstest.MapFS{ + filename: &fstest.MapFile{ + Data: []byte(code), + }, + } + return testFs +} + +func waitForErr(t *testing.T, errChan chan error) error { + t.Helper() + + select { + case err := <-errChan: + return err + case <-time.After(5 * time.Second): + t.Fatalf("timed-out waiting for an error") + } + return nil +} diff --git a/internal/dynamo-browse/services/scriptmanager/serviceopts.go b/internal/dynamo-browse/services/scriptmanager/serviceopts.go new file mode 100644 index 0000000..6841531 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/serviceopts.go @@ -0,0 +1,11 @@ +package scriptmanager + +import "io/fs" + +type ServiceOption func(srv *Service) + +func WithFS(fs ...fs.FS) ServiceOption { + return func(srv *Service) { + srv.lookupPaths = fs + } +} diff --git a/internal/dynamo-browse/services/scriptmanager/types.go b/internal/dynamo-browse/services/scriptmanager/types.go new file mode 100644 index 0000000..ffdb567 --- /dev/null +++ b/internal/dynamo-browse/services/scriptmanager/types.go @@ -0,0 +1,26 @@ +package scriptmanager + +import "context" + +type ScriptPlugin struct { + scriptService *Service + name string + definedCommands map[string]*Command +} + +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) + }) +} diff --git a/internal/dynamo-browse/services/viewsnapshot/service.go b/internal/dynamo-browse/services/viewsnapshot/service.go index ebff08e..791ee90 100644 --- a/internal/dynamo-browse/services/viewsnapshot/service.go +++ b/internal/dynamo-browse/services/viewsnapshot/service.go @@ -27,7 +27,7 @@ func (s *ViewSnapshotService) PushSnapshot(details serialisable.ViewSnapshotDeta return errors.Wrap(err, "cannot get snapshot head") } - if oldHead != nil && oldHead.Details == details { + if oldHead != nil && oldHead.Details.Equals(details, false) { // Attempting to push a duplicate return nil } diff --git a/internal/dynamo-browse/services/viewsnapshot/service_test.go b/internal/dynamo-browse/services/viewsnapshot/service_test.go index ec06439..845be56 100644 --- a/internal/dynamo-browse/services/viewsnapshot/service_test.go +++ b/internal/dynamo-browse/services/viewsnapshot/service_test.go @@ -1,6 +1,9 @@ package viewsnapshot_test import ( + "bytes" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/audax/internal/dynamo-browse/models/queryexpr" "github.com/lmika/audax/internal/dynamo-browse/models/serialisable" "github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore" "github.com/lmika/audax/internal/dynamo-browse/services/viewsnapshot" @@ -14,11 +17,14 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) { ws := testworkspace.New(t) service := viewsnapshot.NewService(workspacestore.NewResultSetSnapshotStore(ws)) + q, _ := queryexpr.Parse("pk = \"abc\"") + qbs, _ := q.SerializeToBytes() // Push some snapshots err := service.PushSnapshot(serialisable.ViewSnapshotDetails{ TableName: "normal-table", - Query: "pk = 'abc'", + Query: qbs, + QueryHash: q.HashCode(), Filter: "", }) assert.NoError(t, err) @@ -27,9 +33,13 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, cnt) + q2, _ := queryexpr.Parse("another = \"test\"") + qbs2, _ := q.SerializeToBytes() + err = service.PushSnapshot(serialisable.ViewSnapshotDetails{ TableName: "abnormal-table", - Query: "pk = 'abc'", + Query: qbs2, + QueryHash: q2.HashCode(), Filter: "fla", }) assert.NoError(t, err) @@ -41,7 +51,8 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) { // Push a duplicate err = service.PushSnapshot(serialisable.ViewSnapshotDetails{ TableName: "abnormal-table", - Query: "pk = 'abc'", + Query: qbs2, + QueryHash: q2.HashCode(), Filter: "fla", }) assert.NoError(t, err) @@ -50,4 +61,34 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 2, cnt) }) + + t.Run("should push expression with placeholder", func(t *testing.T) { + ws := testworkspace.New(t) + service := viewsnapshot.NewService(workspacestore.NewResultSetSnapshotStore(ws)) + + q, _ := queryexpr.Parse("another = $one") + q = q.WithValueParams(map[string]types.AttributeValue{ + "one": &types.AttributeValueMemberS{Value: "bla-di-bla"}, + }) + qbs, _ := q.SerializeToBytes() + + err := service.PushSnapshot(serialisable.ViewSnapshotDetails{ + TableName: "abnormal-table", + Query: qbs, + QueryHash: q.HashCode(), + Filter: "fla", + }) + assert.NoError(t, err) + + vs, err := service.ViewRestore() + assert.NoError(t, err) + assert.Equal(t, "abnormal-table", vs.Details.TableName) + assert.Equal(t, "fla", vs.Details.Filter) + + rq, err := queryexpr.DeserializeFrom(bytes.NewReader(vs.Details.Query)) + assert.NoError(t, err) + assert.Equal(t, "bla-di-bla", rq.ValueParamOrNil("one").(*types.AttributeValueMemberS).Value) + assert.True(t, q.Equal(rq)) + assert.Equal(t, q.HashCode(), rq.HashCode()) + }) } diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index a1bd48a..3fffe9b 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -19,6 +19,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/tableselect" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/utils" + bus "github.com/lmika/events" "github.com/pkg/errors" "log" "os" @@ -43,11 +44,13 @@ type Model struct { settingsController *controllers.SettingsController exportController *controllers.ExportController commandController *commandctrl.CommandController + scriptController *controllers.ScriptController jobController *controllers.JobsController colSelector *colselector.Model itemEdit *dynamoitemedit.Model statusAndPrompt *statusandprompt.StatusAndPrompt tableSelect *tableselect.Model + eventBus *bus.Bus mainViewIndex int @@ -67,12 +70,14 @@ func NewModel( jobController *controllers.JobsController, itemRendererService *itemrenderer.Service, cc *commandctrl.CommandController, + scriptController *controllers.ScriptController, + eventBus *bus.Bus, keyBindingController *controllers.KeyBindingController, defaultKeyMap *keybindings.KeyBindings, ) Model { uiStyles := styles.DefaultStyles - dtv := dynamotableview.New(defaultKeyMap.TableView, columnsController, settingsController, uiStyles) + dtv := dynamotableview.New(defaultKeyMap.TableView, columnsController, settingsController, eventBus, uiStyles) div := dynamoitemview.New(itemRendererService, uiStyles) mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div) @@ -183,6 +188,19 @@ func NewModel( 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), @@ -198,6 +216,7 @@ func NewModel( tableReadController: rc, tableWriteController: wc, commandController: cc, + scriptController: scriptController, jobController: jobController, itemEdit: itemEdit, colSelector: colSelector, diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 02f0c70..a7b3c57 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -12,6 +12,7 @@ import ( "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles" + bus "github.com/lmika/events" table "github.com/lmika/go-bubble-table" "strings" ) @@ -38,6 +39,7 @@ type Model struct { keyBinding *keybindings.TableKeyBinding setting Setting columnsProvider ColumnsProvider + bus *bus.Bus // model state isReadOnly bool @@ -47,7 +49,7 @@ type Model struct { resultSet *models.ResultSet } -func New(keyBinding *keybindings.TableKeyBinding, columnsProvider ColumnsProvider, setting Setting, uiStyles styles.Styles) *Model { +func New(keyBinding *keybindings.TableKeyBinding, columnsProvider ColumnsProvider, setting Setting, bus *bus.Bus, uiStyles styles.Styles) *Model { frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames) isReadOnly := setting.IsReadOnly() @@ -57,6 +59,7 @@ func New(keyBinding *keybindings.TableKeyBinding, columnsProvider ColumnsProvide keyBinding: keyBinding, setting: setting, columnsProvider: columnsProvider, + bus: bus, } model.table = table.New(columnModel{model}, 100, 100) @@ -226,9 +229,11 @@ func (m *Model) selectedItem() (itemTableRow, bool) { func (m *Model) postSelectedItemChanged() tea.Msg { item, ok := m.selectedItem() if !ok { + m.bus.Fire("ui.new-item-selected", item.resultSet, -1) return dynamoitemview.NewItemSelected{ResultSet: item.resultSet, Item: nil} } + m.bus.Fire("ui.new-item-selected", item.resultSet, item.itemIndex) return dynamoitemview.NewItemSelected{ResultSet: item.resultSet, Item: item.item} } diff --git a/internal/dynamo-browse/ui/teamodels/layout/events.go b/internal/dynamo-browse/ui/teamodels/layout/events.go new file mode 100644 index 0000000..5c4a96e --- /dev/null +++ b/internal/dynamo-browse/ui/teamodels/layout/events.go @@ -0,0 +1,3 @@ +package layout + +type RequestLayout struct{} diff --git a/internal/dynamo-browse/ui/teamodels/layout/fullscreen.go b/internal/dynamo-browse/ui/teamodels/layout/fullscreen.go index 849ac97..4ad6754 100644 --- a/internal/dynamo-browse/ui/teamodels/layout/fullscreen.go +++ b/internal/dynamo-browse/ui/teamodels/layout/fullscreen.go @@ -21,8 +21,12 @@ func (f fullScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: f.ready = true + f.w, f.h = msg.Width, msg.Height f.submodel = f.submodel.Resize(msg.Width, msg.Height) return f, nil + case RequestLayout: + f.submodel = f.submodel.Resize(f.w, f.h) + return f, nil } newSubModel, cmd := f.submodel.Update(msg) diff --git a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go index 2831210..1cae636 100644 --- a/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go +++ b/internal/dynamo-browse/ui/teamodels/statusandprompt/model.go @@ -9,21 +9,21 @@ import ( "github.com/lmika/audax/internal/common/ui/events" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout" "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/utils" - "log" ) // StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt // event is received, focus will be torn away and the user will be given a prompt the enter text. type StatusAndPrompt struct { - model layout.ResizingModel - style Style - modeLine string - statusMessage string - spinner spinner.Model - spinnerVisible bool - pendingInput *events.PromptForInputMsg - textInput textinput.Model - width int + model layout.ResizingModel + style Style + modeLine string + statusMessage string + spinner spinner.Model + spinnerVisible bool + pendingInput *events.PromptForInputMsg + textInput textinput.Model + width, height int + lastModeLineHeight int } type Style struct { @@ -84,11 +84,17 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.textInput.Focus() s.textInput.SetValue("") s.pendingInput = &msg - return s, nil case tea.KeyMsg: if s.pendingInput != nil { switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc: + if s.pendingInput.OnCancel != nil { + pendingInput := s.pendingInput + cc.Add(func() tea.Msg { + m := pendingInput.OnCancel() + return m + }) + } s.pendingInput = nil case tea.KeyEnter: pendingInput := s.pendingInput @@ -96,7 +102,6 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, func() tea.Msg { m := pendingInput.OnDone(s.textInput.Value()) - log.Printf("return msg type = %T", m) return m } default: @@ -116,6 +121,11 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.spinner = cc.Collect(s.spinner.Update(msg)).(spinner.Model) } s.model = cc.Collect(s.model.Update(msg)).(layout.ResizingModel) + + // If the height of the modeline has changed, request a relayout + if s.lastModeLineHeight != lipgloss.Height(s.viewStatus()) { + cc.Add(events.SetTeaMessage(layout.RequestLayout{})) + } return s, cc.Cmd() } @@ -129,7 +139,9 @@ func (s *StatusAndPrompt) View() string { func (s *StatusAndPrompt) Resize(w, h int) layout.ResizingModel { s.width = w - submodelHeight := h - lipgloss.Height(s.viewStatus()) + s.height = h + s.lastModeLineHeight = lipgloss.Height(s.viewStatus()) + submodelHeight := h - s.lastModeLineHeight s.model = s.model.Resize(w, submodelHeight) return s }