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 }