Initial version of scripting (#40)

* scripting: added service and controller for scripting

* scripting: have got prompts working

Scripts are now running in a separate go-routine.  When a prompt is encountered, the
script is paused and the user is prompted for input.  This means that the script no
longer needs to worry about synchronisation issues.

* scripting: started working on the session methods

* scripting: added methods to get items and attributes

* scripting: have got loading of scripts working

These act more like plugins and allow defining new commands.

* scripting: have got script scheduling working

Scripts are now executed on a dedicated goroutine and only one script can run at any one time.

* scripting: added session.set_result_set(rs)

* scripting: upgraded tamarin to 0.14

* scripting: started working on set_value

* tamarin: replaced ad-hoc path with query expressions

* scripting: changed value() and set_value() to attr() and set_attr()

Also added 'delete_attr()'

* scripting: added os.exec()

This method is controlled by permissions which govern whether shellouts are allowed
Also fixed a resizing bug with the status window which was not properly handling status messages with newlines

* scripting: added the session.current_item() method

* scripting: added placeholders to query expressions

* scripting: added support for setting and deleteing items with placeholders

Also refactored the dot AST type so that it support placeholders.  Placeholders are not yet supported
for subrefs yet, they need to be identifiers.

* scripting: made setting the result-set push the current result-set to the backstack

* scripting: started working on byte encoding of attribute values

* scripting: finished attrcodec

* scripting: integrated codec into expression

* scripting: added equals and hashcode to queryexpr

This finally finishes the work required to store queries in the backstack

* scripting: fixed some bugs with the back-stack

* scripting: upgraded Tamarin

* scripting: removed some commented out code
This commit is contained in:
Leon Mika 2023-01-10 22:27:13 +11:00 committed by GitHub
parent cd9700569c
commit c89b09447c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 4588 additions and 281 deletions

View file

@ -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 {

32
go.mod
View file

@ -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
)

86
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -15,3 +15,7 @@ type CommandList struct {
parent *CommandList
}
type CommandLookupExtension interface {
LookupCommand(name string) Command
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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])
})
}

View file

@ -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))

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,5 @@
package controllers
type UIStateProvider interface {
SelectedRowIndex() int
}

View file

@ -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)
})
}
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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},
}

View file

@ -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
}

View file

@ -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))
}
}
}

View file

@ -13,6 +13,8 @@ type ResultSet struct {
type Queryable interface {
String() string
SerializeToBytes() ([]byte, error)
HashCode() uint64
Plan(tableInfo *TableInfo) (*QueryExecutionPlan, error)
}

View file

@ -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)
}

View file

@ -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 ""
}

View file

@ -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 {

View file

@ -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()

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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()

View file

@ -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 + "'"
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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
}

View file

@ -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()
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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())
})
}

View file

@ -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,

View file

@ -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}
}

View file

@ -0,0 +1,3 @@
package layout
type RequestLayout struct{}

View file

@ -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)

View file

@ -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
}