Merge branch 'feature/ucl'
All checks were successful
ci / build (push) Successful in 3m18s

This commit is contained in:
Leon Mika 2025-05-26 21:54:55 +10:00
commit 8b5b5798da
55 changed files with 2205 additions and 3994 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
debug.log
.DS_store
.idea

View file

@ -4,9 +4,11 @@ import (
"context"
"flag"
"fmt"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"log"
"net"
"os"
"strings"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
@ -26,7 +28,6 @@ import (
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs"
keybindings_service "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui"
@ -44,6 +45,7 @@ func main() {
var flagDefaultLimit = flag.Int("default-limit", 0, "default limit for queries and scans")
var flagWorkspace = flag.String("w", "", "workspace file")
var flagQuery = flag.String("q", "", "run query")
var flagExtDir = flag.String("ext-dir", "$HOME/.config/dynamo-browse/ext:$HOME/.config/dynamo-browse/.", "directory to search for extensions")
flag.Parse()
ctx := context.Background()
@ -104,7 +106,6 @@ func main() {
tableService := tables.NewService(dynamoProvider, settingStore)
workspaceService := viewsnapshot.NewService(resultSetSnapshotStore)
itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo)
scriptManagerService := scriptmanager.New()
jobsService := jobs.NewService(eventBus)
inputHistoryService := inputhistory.New(inputHistoryStore)
@ -119,7 +120,6 @@ func main() {
inputHistoryService,
eventBus,
pasteboardProvider,
scriptManagerService,
*flagTable,
)
tableWriteController := controllers.NewTableWriteController(state, tableService, jobsController, tableReadController, settingStore)
@ -127,7 +127,6 @@ func main() {
exportController := controllers.NewExportController(state, tableService, jobsController, columnsController, pasteboardProvider)
settingsController := controllers.NewSettingsController(settingStore, eventBus)
keyBindings := keybindings.Default()
scriptController := controllers.NewScriptController(scriptManagerService, tableReadController, jobsController, settingsController, eventBus)
if *flagQuery != "" {
if *flagTable == "" {
@ -157,10 +156,23 @@ func main() {
}
keyBindingService := keybindings_service.NewService(keyBindings)
keyBindingController := controllers.NewKeyBindingController(keyBindingService, scriptController)
keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil)
commandController := commandctrl.NewCommandController(inputHistoryService)
commandController.AddCommandLookupExtension(scriptController)
stdCommands := cmdpacks.NewStandardCommands(
tableService,
state,
tableReadController,
tableWriteController,
exportController,
keyBindingController,
pasteboardProvider,
settingsController,
)
commandController, err := commandctrl.NewCommandController(inputHistoryService, stdCommands)
if err != nil {
cli.Fatalf("cannot setup command controller: %v", err)
}
commandController.SetCommandCompletionProvider(columnsController)
model := ui.NewModel(
@ -172,12 +184,12 @@ func main() {
jobsController,
itemRendererService,
commandController,
scriptController,
eventBus,
keyBindingController,
pasteboardProvider,
keyBindings,
)
commandController.SetUIStateProvider(&model)
// Pre-determine if layout has dark background. This prevents calls for creating a list to hang.
osstyle.DetectCurrentScheme()
@ -185,8 +197,12 @@ func main() {
p := tea.NewProgram(model, tea.WithAltScreen())
jobsController.SetMessageSender(p.Send)
scriptController.Init()
scriptController.SetMessageSender(p.Send)
if err := commandController.LoadExtensions(context.Background(), strings.Split(*flagExtDir, string(os.PathListSeparator))); err != nil {
fmt.Printf("Unable to load extensions: %v", err)
}
go commandController.StartMessageSender(p.Send)
log.Println("launching")
if err := p.Start(); err != nil {

16
go.mod
View file

@ -1,11 +1,11 @@
module github.com/lmika/dynamo-browse
go 1.22
go 1.24
toolchain go1.22.0
toolchain go1.24.0
require (
github.com/alecthomas/participle/v2 v2.0.0-beta.5
github.com/alecthomas/participle/v2 v2.1.1
github.com/asdine/storm v2.1.2+incompatible
github.com/aws/aws-sdk-go-v2 v1.18.1
github.com/aws/aws-sdk-go-v2/config v1.18.27
@ -20,16 +20,16 @@ require (
github.com/charmbracelet/lipgloss v0.6.0
github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e
github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538
github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe
github.com/mattn/go-runewidth v0.0.14
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70
github.com/muesli/reflow v0.3.0
github.com/pkg/errors v0.9.1
github.com/risor-io/risor v1.4.0
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.9.0
golang.design/x/clipboard v0.6.2
golang.org/x/exp v0.0.0-20230108222341-4b8118a2686a
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e
)
require (
@ -55,7 +55,7 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
@ -64,8 +64,8 @@ require (
github.com/muesli/termenv v0.13.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sahilm/fuzzy v0.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
go.etcd.io/bbolt v1.3.6 // indirect
golang.org/x/exp/shiny v0.0.0-20230213192124-5e25df0256eb // indirect

32
go.sum
View file

@ -3,12 +3,12 @@ github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8=
github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879 h1:M5ptEKnqKqpFTKbe+p5zEf3ro1deJ6opUz5j3g3/ErQ=
github.com/Sereal/Sereal v0.0.0-20220220040404-e0d1e550e879/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/alecthomas/assert/v2 v2.0.3 h1:WKqJODfOiQG0nEJKFKzDIG3E29CN2/4zR9XGJzKIkbg=
github.com/alecthomas/assert/v2 v2.0.3/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=
github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35a3WghArZ/Hbebrjo=
github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM=
github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0=
github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ=
github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8=
github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c=
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/asdine/storm v2.1.2+incompatible h1:dczuIkyqwY2LrtXPz8ixMrU/OFgZp71kbKTHGrXYt/Q=
github.com/asdine/storm v2.1.2+incompatible/go.mod h1:RarYDc9hq1UPLImuiXK3BIWPJLdIygvV3PsInK0FbVQ=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@ -105,8 +105,8 @@ github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGy
github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM=
github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538 h1:dtMPRNoDqDnnP3HgOvYhswcJVSqdISkYlCtGOjTqg6Q=
github.com/lmika/go-bubble-table v0.2.2-0.20220616114432-6bbb2995e538/go.mod h1:0RT1upgKZ6qZ6B1SqseE3wWsPjSQRv/G/HjpYK8jNsg=
github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI=
github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ=
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f h1:tz68Lhc1oR15HVz69IGbtdukdH0x70kBDEvvj5pTXyE=
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f/go.mod h1:zHQvhjGXRro/Xp2C9dbC+ZUpE0gL4GYW75x1lk7hwzI=
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18=
github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe/go.mod h1:qpdOkLougV5Yry4Px9f1w1pNMavcr6Z67VW5Ro+vW5I=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@ -139,28 +139,23 @@ github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/risor-io/risor v1.4.0 h1:G17pWgq+N06jWvnaJVwos89tC5C4VMjqwGYRrTWleRM=
github.com/risor-io/risor v1.4.0/go.mod h1:+s/FeK0CdsTCCNZsHSp8EJa3u3mMrhqtNGLCv/GcW8Y=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
@ -250,6 +245,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e h1:N+HzQUunDUvdjAzbSDtHQZVZ1k+XHbVgbNwmc+EKmlQ=
ucl.lmika.dev v0.0.0-20250525023717-3076897eb73e/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=

View file

@ -0,0 +1,47 @@
package cmdpacks
import (
"context"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"ucl.lmika.dev/ucl"
)
type optModule struct {
settingsController *controllers.SettingsController
}
func (m optModule) pbSet(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
name string
newVale string
)
if args.NArgs() == 1 {
if err := args.Bind(&name); err != nil {
return nil, err
}
} else {
if err := args.Bind(&name, &newVale); err != nil {
return nil, err
}
}
commandctrl.PostMsg(ctx, m.settingsController.SetSetting(name, newVale))
return nil, nil
}
func moduleOpt(
settingsController *controllers.SettingsController,
) ucl.Module {
m := &optModule{
settingsController: settingsController,
}
return ucl.Module{
Name: "opt",
Builtins: map[string]ucl.BuiltinHandler{
"set": m.pbSet,
},
}
}

View file

@ -0,0 +1,46 @@
package cmdpacks
import (
"context"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
"ucl.lmika.dev/ucl"
)
type pbModule struct {
pasteboardProvider services.PasteboardProvider
}
func (m pbModule) pbGet(ctx context.Context, args ucl.CallArgs) (any, error) {
s, ok := m.pasteboardProvider.ReadText()
if !ok {
return "", nil
}
return s, nil
}
func (m pbModule) pbPut(ctx context.Context, args ucl.CallArgs) (any, error) {
var s string
if err := args.Bind(&s); err != nil {
return nil, err
}
if err := m.pasteboardProvider.WriteText([]byte(s)); err != nil {
return nil, err
}
return s, nil
}
func modulePB(
pasteboardProvider services.PasteboardProvider,
) ucl.Module {
m := &pbModule{
pasteboardProvider: pasteboardProvider,
}
return ucl.Module{
Name: "pb",
Builtins: map[string]ucl.BuiltinHandler{
"get": m.pbGet,
"put": m.pbPut,
},
}
}

View file

@ -0,0 +1,308 @@
package cmdpacks
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/pkg/errors"
"time"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
)
type rsModule struct {
tableService *tables.Service
state *controllers.State
}
var rsNewDoc = repl.Doc{
Brief: "Creates a new, empty result set",
Usage: "[-table NAME]",
Detailed: `
The result set assumes the details of the current table. If no table is specified,
the command will return an error.
`,
}
func (rs *rsModule) rsNew(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var tableInfo *models.TableInfo
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return nil, err
}
tableInfo, err = rs.tableService.Describe(ctx, tblName)
if err != nil {
return nil, err
}
} else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil {
tableInfo = currRs.TableInfo
} else {
return nil, errors.New("no table specified")
}
return newResultSetProxy(&models.ResultSet{
TableInfo: tableInfo,
Created: time.Now(),
}), nil
}
var rsQueryDoc = repl.Doc{
Brief: "Runs a query and returns the results as a result-set",
Usage: "QUERY [ARGS] [-table NAME]",
Args: []repl.ArgDoc{
{Name: "query", Brief: "Query expression to run"},
{Name: "args", Brief: "Hash of argument values to substitute into the query"},
{Name: "-table", Brief: "Optional table name to use for the query"},
},
Detailed: `
If no table is specified, then the value of @table will be used. If this is unavailable,
the command will return an error.
`,
}
func parseQuery(
ctx context.Context,
args ucl.CallArgs,
currentRS *models.ResultSet,
tablesService *tables.Service,
) (*queryexpr.QueryExpr, *models.TableInfo, error) {
var expr string
if err := args.Bind(&expr); err != nil {
return nil, nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return nil, nil, err
}
if args.NArgs() > 0 {
var queryArgs ucl.Hashable
if err := args.Bind(&queryArgs); err != nil {
return nil, nil, err
}
queryNames := map[string]string{}
queryValues := map[string]types.AttributeValue{}
queryArgs.Each(func(k string, v ucl.Object) error {
if v == nil {
return nil
}
queryNames[k] = v.String()
switch v.(type) {
case ucl.StringObject:
queryValues[k] = &types.AttributeValueMemberS{Value: v.String()}
case ucl.IntObject:
queryValues[k] = &types.AttributeValueMemberN{Value: v.String()}
// TODO: other types
}
return nil
})
q = q.WithNameParams(queryNames).WithValueParams(queryValues)
}
var tableInfo *models.TableInfo
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return nil, nil, err
}
tableInfo, err = tablesService.Describe(ctx, tblName)
if err != nil {
return nil, nil, err
}
} else if currentRS != nil && currentRS.TableInfo != nil {
tableInfo = currentRS.TableInfo
} else {
return nil, nil, errors.New("no table specified")
}
return q, tableInfo, nil
}
func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
q, tableInfo, err := parseQuery(ctx, args, rs.state.ResultSet(), rs.tableService)
if err != nil {
return nil, err
}
newResultSet, err := rs.tableService.ScanOrQuery(context.Background(), tableInfo, q, nil)
if err != nil {
return nil, err
}
return newResultSetProxy(newResultSet), nil
}
var rsScanDoc = repl.Doc{
Brief: "Performs a scan of the table and returns the results as a result-set",
Usage: "[-table NAME]",
Args: []repl.ArgDoc{
{Name: "-table", Brief: "Optional table name to use for the query"},
},
Detailed: `
If no table is specified, then the value of @table will be used. If this is unavailable,
the command will return an error.
`,
}
func (rs *rsModule) rsScan(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var tableInfo *models.TableInfo
if args.HasSwitch("table") {
var tblName string
if err := args.BindSwitch("table", &tblName); err != nil {
return nil, err
}
tableInfo, err = rs.tableService.Describe(ctx, tblName)
if err != nil {
return nil, err
}
} else if currRs := rs.state.ResultSet(); currRs != nil && currRs.TableInfo != nil {
tableInfo = currRs.TableInfo
} else {
return nil, errors.New("no table specified")
}
newResultSet, err := rs.tableService.Scan(context.Background(), tableInfo)
if err != nil {
return nil, err
}
return newResultSetProxy(newResultSet), nil
}
func (rs *rsModule) rsFilter(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
rsProxy SimpleProxy[*models.ResultSet]
filter string
)
if err := args.Bind(&rsProxy, &filter); err != nil {
return nil, err
}
newResultSet := rs.tableService.Filter(rsProxy.ProxyValue(), filter)
return newResultSetProxy(newResultSet), nil
}
var rsNextPageDoc = repl.Doc{
Brief: "Returns the next page of the passed in result-set",
Usage: "RESULT_SET",
Args: []repl.ArgDoc{
{Name: "result-set", Brief: "Result set to fetch the next page of"},
},
Detailed: `
If no next page exists, the command will return nil.
`,
}
func (rs *rsModule) rsNextPage(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var rsProxy SimpleProxy[*models.ResultSet]
if err := args.Bind(&rsProxy); err != nil {
return nil, err
}
if !rsProxy.value.HasNextPage() {
return nil, nil
}
nextPage, err := rs.tableService.NextPage(ctx, rsProxy.value)
if err != nil {
return nil, err
}
return newResultSetProxy(nextPage), nil
}
func (rs *rsModule) rsUnion(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var rsProxy1, rsProxy2 SimpleProxy[*models.ResultSet]
if err := args.Bind(&rsProxy1, &rsProxy2); err != nil {
return nil, err
}
return newResultSetProxy(rsProxy1.ProxyValue().MergeWith(rsProxy2.ProxyValue())), nil
}
func (rs *rsModule) rsSet(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var (
item itemProxy
expr string
val ucl.Object
)
if err := args.Bind(&item, &expr, &val); err != nil {
return nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return nil, err
}
// TEMP: attribute is always S
if err := q.SetEvalItem(item.item, &types.AttributeValueMemberS{Value: val.String()}); err != nil {
return nil, err
}
item.resultSet.SetDirty(item.idx, true)
commandctrl.QueueRefresh(ctx)
return item, nil
}
func (rs *rsModule) rsDel(ctx context.Context, args ucl.CallArgs) (_ any, err error) {
var (
item itemProxy
expr string
)
if err := args.Bind(&item, &expr); err != nil {
return nil, err
}
q, err := queryexpr.Parse(expr)
if err != nil {
return nil, err
}
if err := q.DeleteAttribute(item.item); err != nil {
return nil, err
}
item.resultSet.SetDirty(item.idx, true)
commandctrl.QueueRefresh(ctx)
return item, nil
}
func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module {
m := &rsModule{
tableService: tableService,
state: state,
}
return ucl.Module{
Name: "rs",
Builtins: map[string]ucl.BuiltinHandler{
"new": m.rsNew,
"query": m.rsQuery,
"scan": m.rsScan,
"filter": m.rsFilter,
"next-page": m.rsNextPage,
"union": m.rsUnion,
"set": m.rsSet,
"del": m.rsDel,
},
}
}

View file

@ -0,0 +1,154 @@
package cmdpacks_test
import (
"fmt"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestModRS_New(t *testing.T) {
svc := newService(t)
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:new`)
assert.NoError(t, err)
assert.IsType(t, rsProxy, cmdpacks.SimpleProxy[*models.ResultSet]{})
}
func TestModRS_NextPage(t *testing.T) {
t.Run("multiple pages", func(t *testing.T) {
svc := newService(t, withDataGenerator(largeTestData), withTable("large-table"), withDefaultLimit(20))
hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`)
assert.NoError(t, err)
assert.True(t, hasNextPage.(bool))
// Page 2
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`)
assert.NoError(t, err)
assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy)
assert.Equal(t, 20, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items()))
hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset).HasNextPage`)
assert.NoError(t, err)
assert.True(t, hasNextPage.(bool))
// Page 3
rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset | rs:next-page`)
assert.NoError(t, err)
assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy)
assert.Equal(t, 10, len(rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue().Items()))
hasNextPage, err = svc.CommandController.ExecuteAndWait(t.Context(), `(rs:next-page @resultset | rs:next-page).HasNextPage`)
assert.NoError(t, err)
assert.False(t, hasNextPage.(bool))
// Last page
rsProxy, err = svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page (rs:next-page @resultset | rs:next-page)`)
assert.NoError(t, err)
assert.Nil(t, rsProxy)
})
t.Run("only one page", func(t *testing.T) {
svc := newService(t)
hasNextPage, err := svc.CommandController.ExecuteAndWait(t.Context(), `@resultset.HasNextPage`)
assert.NoError(t, err)
assert.False(t, hasNextPage.(bool))
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:next-page @resultset`)
assert.NoError(t, err)
assert.Nil(t, rsProxy)
})
}
func TestModRS_Union(t *testing.T) {
svc := newService(t, withDefaultLimit(2))
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `
$mr = rs:union @resultset (rs:next-page @resultset)
assert (eq (len $mr.Items) 3) "expected len == 3"
assert (eq $mr.Items.(0).pk "abc") "expected 0.pk"
assert (eq $mr.Items.(0).sk "111") "expected 0.sk"
assert (eq $mr.Items.(1).pk "abc") "expected 1.pk"
assert (eq $mr.Items.(1).sk "222") "expected 1.sk"
assert (eq $mr.Items.(2).pk "bbb") "expected 2.pk"
assert (eq $mr.Items.(2).sk "131") "expected 2.sk"
$mr
`)
assert.NoError(t, err)
assert.IsType(t, cmdpacks.SimpleProxy[*models.ResultSet]{}, rsProxy)
rs := rsProxy.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue()
assert.Equal(t, 3, len(rs.Items()))
}
func TestModRS_Query(t *testing.T) {
tests := []struct {
descr string
cmd string
wantRows []int
}{
{
descr: "query with pk 1",
cmd: `rs:query 'pk="abc"' -table service-test-data`,
wantRows: []int{0, 1},
},
{
descr: "query with pk 2",
cmd: `rs:query 'pk="bbb"' -table service-test-data`,
wantRows: []int{2},
},
{
descr: "query with sk 1",
cmd: `rs:query 'sk="222"' -table service-test-data`,
wantRows: []int{1},
},
{
descr: "query with args 1",
cmd: `rs:query 'pk=$v' [v:'abc'] -table service-test-data`,
wantRows: []int{0, 1},
},
{
descr: "query with args 2",
cmd: `rs:query ':k=$v' [k:'pk' v:'abc'] -table service-test-data`,
wantRows: []int{0, 1},
},
{
descr: "query with args 3",
cmd: `rs:query ':k=$v' [k:'beta' v:1231] -table service-test-data`,
wantRows: []int{1},
},
{
descr: "query with args with no table set",
cmd: `rs:query ':k=$v' [k:'beta' v:1231]`,
wantRows: []int{1},
},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
svc := newService(t)
res, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd)
assert.NoError(t, err)
rs := res.(cmdpacks.SimpleProxy[*models.ResultSet]).ProxyValue()
assert.Len(t, rs.Items(), len(tt.wantRows))
for i, rowIndex := range tt.wantRows {
for key, want := range svc.testData[0].Data[rowIndex] {
have, ok := rs.Items()[i].AttributeValueAsString(key)
assert.True(t, ok)
assert.Equal(t, fmt.Sprint(want), have)
}
}
})
}
}

View file

@ -0,0 +1,210 @@
package cmdpacks
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"ucl.lmika.dev/ucl"
)
type uiModule struct {
tableService *tables.Service
state *controllers.State
ckb *customKeyBinding
readController *controllers.TableReadController
}
func (m *uiModule) uiCommand(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
name string
cmd ucl.Invokable
)
if err := args.Bind(&name, &cmd); err != nil {
return nil, err
}
invoker := commandctrl.GetInvoker(ctx)
invoker.Inst().SetBuiltinInvokable(name, cmd)
return nil, nil
}
func (m *uiModule) uiPrompt(ctx context.Context, args ucl.CallArgs) (any, error) {
var prompt string
if err := args.Bind(&prompt); err != nil {
return nil, err
}
resChan := make(chan string)
go func() {
commandctrl.PostMsg(ctx, events.PromptForInput(prompt, nil, func(value string) tea.Msg {
resChan <- value
return nil
}))
}()
select {
case value := <-resChan:
return value, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiConfirm(ctx context.Context, args ucl.CallArgs) (any, error) {
var prompt string
if err := args.Bind(&prompt); err != nil {
return nil, err
}
resChan := make(chan bool)
go func() {
commandctrl.PostMsg(ctx, events.Confirm(prompt, func(value bool) tea.Msg {
resChan <- value
return nil
}))
}()
select {
case value := <-resChan:
return value, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiPromptTable(ctx context.Context, args ucl.CallArgs) (any, error) {
tables, err := m.tableService.ListTables(context.Background())
if err != nil {
return nil, err
}
resChan := make(chan string)
go func() {
commandctrl.PostMsg(ctx, controllers.PromptForTableMsg{
Tables: tables,
OnSelected: func(tableName string) tea.Msg {
resChan <- tableName
return nil
},
})
}()
select {
case value := <-resChan:
return value, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (m *uiModule) uiBind(ctx context.Context, args ucl.CallArgs) (any, error) {
var (
bindName string
key string
inv ucl.Invokable
)
if args.NArgs() == 2 {
if err := args.Bind(&key, &inv); err != nil {
return nil, err
}
bindName = "custom." + key
} else {
if err := args.Bind(&bindName, &key, &inv); err != nil {
return nil, err
}
}
invoker := commandctrl.GetInvoker(ctx)
m.ckb.bindings[bindName] = func() tea.Msg {
return invoker.Invoke(inv, nil)
}
m.ckb.keyBindings[key] = bindName
return nil, nil
}
func (m *uiModule) uiQuery(ctx context.Context, args ucl.CallArgs) (any, error) {
q, tableInfo, err := parseQuery(ctx, args, m.state.ResultSet(), m.tableService)
if err != nil {
return nil, err
}
commandctrl.PostMsg(ctx, m.readController.RunQuery(q, tableInfo))
return nil, nil
}
func (m *uiModule) uiFilter(ctx context.Context, args ucl.CallArgs) (any, error) {
var filter string
if err := args.Bind(&filter); err != nil {
return nil, err
}
commandctrl.PostMsg(ctx, m.readController.Filter(filter))
return nil, nil
}
func moduleUI(
tableService *tables.Service,
state *controllers.State,
readController *controllers.TableReadController,
) (ucl.Module, controllers.CustomKeyBindingSource) {
m := &uiModule{
tableService: tableService,
state: state,
readController: readController,
ckb: &customKeyBinding{
bindings: map[string]tea.Cmd{},
keyBindings: map[string]string{},
},
}
return ucl.Module{
Name: "ui",
Builtins: map[string]ucl.BuiltinHandler{
"command": m.uiCommand,
"prompt": m.uiPrompt,
"prompt-table": m.uiPromptTable,
"confirm": m.uiConfirm,
"query": m.uiQuery,
"filter": m.uiFilter,
"bind": m.uiBind,
},
}, m.ckb
}
type customKeyBinding struct {
bindings map[string]tea.Cmd
keyBindings map[string]string
}
func (c *customKeyBinding) LookupBinding(theKey string) string {
return c.keyBindings[theKey]
}
func (c *customKeyBinding) CustomKeyCommand(key string) tea.Cmd {
bindingName, ok := c.keyBindings[key]
if !ok {
return nil
}
binding, ok := c.bindings[bindingName]
if !ok {
return nil
}
return binding
}
func (c *customKeyBinding) UnbindKey(key string) {
delete(c.keyBindings, key)
}
func (c *customKeyBinding) Rebind(bindingName string, newKey string) error {
c.keyBindings[newKey] = bindingName
return nil
}

View file

@ -0,0 +1,216 @@
package cmdpacks
import (
"fmt"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"maps"
"strconv"
"ucl.lmika.dev/ucl"
)
type proxyInfo[T comparable] struct {
fields map[string]func(t T) ucl.Object
lenFunc func(t T) int
strFunc func(t T) string
}
type SimpleProxy[T comparable] struct {
value T
proxyInfo *proxyInfo[T]
}
func (tp SimpleProxy[T]) ProxyValue() T {
return tp.value
}
func (tp SimpleProxy[T]) String() string {
if tp.proxyInfo.strFunc != nil {
return tp.proxyInfo.strFunc(tp.value)
}
return fmt.Sprint(tp.value)
}
func (tp SimpleProxy[T]) Truthy() bool {
var zeroT T
return tp.value != zeroT
}
func (tp SimpleProxy[T]) Len() int {
if tp.proxyInfo.lenFunc != nil {
return tp.proxyInfo.lenFunc(tp.value)
}
return len(tp.proxyInfo.fields)
}
func (tp SimpleProxy[T]) Value(k string) ucl.Object {
f, ok := tp.proxyInfo.fields[k]
if !ok {
return nil
}
return f(tp.value)
}
func (tp SimpleProxy[T]) Each(fn func(k string, v ucl.Object) error) error {
for key := range maps.Keys(tp.proxyInfo.fields) {
if err := fn(key, tp.Value(key)); err != nil {
return err
}
}
return nil
}
type simpleProxyList[T comparable] struct {
values []T
converter func(T) ucl.Object
}
func newSimpleProxyList[T comparable](values []T, converter func(T) ucl.Object) simpleProxyList[T] {
return simpleProxyList[T]{values: values, converter: converter}
}
func (tp simpleProxyList[T]) String() string {
return fmt.Sprint(tp.values)
}
func (tp simpleProxyList[T]) Truthy() bool {
return len(tp.values) > 0
}
func (tp simpleProxyList[T]) Len() int {
return len(tp.values)
}
func (tp simpleProxyList[T]) Index(k int) ucl.Object {
return tp.converter(tp.values[k])
}
func newResultSetProxy(rs *models.ResultSet) ucl.Object {
return SimpleProxy[*models.ResultSet]{value: rs, proxyInfo: resultSetProxyFields}
}
var resultSetProxyFields = &proxyInfo[*models.ResultSet]{
lenFunc: func(t *models.ResultSet) int { return len(t.Items()) },
strFunc: func(t *models.ResultSet) string {
return fmt.Sprintf("ResultSet(%v:%d)", t.TableInfo.Name, len(t.Items()))
},
fields: map[string]func(t *models.ResultSet) ucl.Object{
"Table": func(t *models.ResultSet) ucl.Object { return newTableProxy(t.TableInfo) },
"Items": func(t *models.ResultSet) ucl.Object { return resultSetItemsProxy{t} },
"HasNextPage": func(t *models.ResultSet) ucl.Object { return ucl.BoolObject(t.HasNextPage()) },
},
}
func newTableProxy(table *models.TableInfo) ucl.Object {
return SimpleProxy[*models.TableInfo]{value: table, proxyInfo: tableProxyFields}
}
var tableProxyFields = &proxyInfo[*models.TableInfo]{
strFunc: func(t *models.TableInfo) string {
return fmt.Sprintf("Table(%v)", t.Name)
},
fields: map[string]func(t *models.TableInfo) ucl.Object{
"Name": func(t *models.TableInfo) ucl.Object { return ucl.StringObject(t.Name) },
"Keys": func(t *models.TableInfo) ucl.Object { return newKeyAttributeProxy(t.Keys) },
"DefinedAttributes": func(t *models.TableInfo) ucl.Object { return ucl.StringListObject(t.DefinedAttributes) },
"GSIs": func(t *models.TableInfo) ucl.Object { return newSimpleProxyList(t.GSIs, newGSIProxy) },
},
}
func newKeyAttributeProxy(keyAttrs models.KeyAttribute) ucl.Object {
return SimpleProxy[models.KeyAttribute]{value: keyAttrs, proxyInfo: keyAttributeProxyFields}
}
var keyAttributeProxyFields = &proxyInfo[models.KeyAttribute]{
strFunc: func(t models.KeyAttribute) string {
return fmt.Sprintf("KeyAttribute(%v,%v)", t.PartitionKey, t.SortKey)
},
fields: map[string]func(t models.KeyAttribute) ucl.Object{
"PK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) },
"SK": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) },
},
}
func newGSIProxy(gsi models.TableGSI) ucl.Object {
return SimpleProxy[models.TableGSI]{value: gsi, proxyInfo: gsiProxyFields}
}
var gsiProxyFields = &proxyInfo[models.TableGSI]{
strFunc: func(t models.TableGSI) string {
return fmt.Sprintf("TableGSI(%v,(%v,%v))", t.Name, t.Keys.PartitionKey, t.Keys.SortKey)
},
fields: map[string]func(t models.TableGSI) ucl.Object{
"Name": func(t models.TableGSI) ucl.Object { return ucl.StringObject(t.Name) },
"Keys": func(t models.TableGSI) ucl.Object { return newKeyAttributeProxy(t.Keys) },
},
}
type resultSetItemsProxy struct {
resultSet *models.ResultSet
}
func (ip resultSetItemsProxy) String() string {
return "RSItem()"
}
func (ip resultSetItemsProxy) Truthy() bool {
return len(ip.resultSet.Items()) > 0
}
func (tp resultSetItemsProxy) Len() int {
return len(tp.resultSet.Items())
}
func (tp resultSetItemsProxy) Index(k int) ucl.Object {
return itemProxy{resultSet: tp.resultSet, idx: k, item: tp.resultSet.Items()[k]}
}
type itemProxy struct {
resultSet *models.ResultSet
idx int
item models.Item
}
func (ip itemProxy) String() string {
return fmt.Sprintf("RSItems(%v)", len(ip.item))
}
func (ip itemProxy) Truthy() bool {
return len(ip.item) > 0
}
func (tp itemProxy) Len() int {
return len(tp.item)
}
func (tp itemProxy) Value(k string) ucl.Object {
f, ok := tp.item[k]
if !ok {
return nil
}
return convertAttributeValueToUCLObject(f)
}
func (tp itemProxy) Each(fn func(k string, v ucl.Object) error) error {
for key := range maps.Keys(tp.item) {
if err := fn(key, tp.Value(key)); err != nil {
return err
}
}
return nil
}
func convertAttributeValueToUCLObject(attrValue types.AttributeValue) ucl.Object {
switch t := attrValue.(type) {
case *types.AttributeValueMemberS:
return ucl.StringObject(t.Value)
case *types.AttributeValueMemberN:
i, err := strconv.ParseInt(t.Value, 10, 64)
if err != nil {
return nil
}
return ucl.IntObject(i)
}
// TODO: the rest
return nil
}

View file

@ -0,0 +1,62 @@
package cmdpacks
import (
"context"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/pkg/errors"
)
type tablePVar struct {
state *controllers.State
}
func (rs tablePVar) Get(ctx context.Context) (any, error) {
return newTableProxy(rs.state.ResultSet().TableInfo), nil
}
type resultSetPVar struct {
state *controllers.State
readController *controllers.TableReadController
}
func (rs resultSetPVar) Get(ctx context.Context) (any, error) {
return newResultSetProxy(rs.state.ResultSet()), nil
}
func (rs resultSetPVar) Set(ctx context.Context, value any) error {
rsVal, ok := value.(SimpleProxy[*models.ResultSet])
if !ok {
return errors.New("new value to @resultset is nil or not a result set")
}
msg := rs.readController.SetResultSet(rsVal.value)
commandctrl.PostMsg(ctx, msg)
return nil
}
type itemPVar struct {
state *controllers.State
}
func (rs itemPVar) Get(ctx context.Context) (any, error) {
selItem, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
return itemProxy{rs.state.ResultSet(), selItem, rs.state.ResultSet().Items()[selItem]}, nil
}
func (rs itemPVar) Set(ctx context.Context, value any) error {
rsVal, ok := value.(itemProxy)
if !ok {
return errors.New("new value to @item is not an item")
}
if msg := commandctrl.SetSelectedItemIndex(ctx, rsVal.idx); msg != nil {
commandctrl.PostMsg(ctx, msg)
}
return nil
}

View file

@ -0,0 +1,440 @@
package cmdpacks
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/pkg/errors"
"ucl.lmika.dev/repl"
"ucl.lmika.dev/ucl"
)
type StandardCommands struct {
TableService *tables.Service
State *controllers.State
ReadController *controllers.TableReadController
WriteController *controllers.TableWriteController
ExportController *controllers.ExportController
KeyBindingController *controllers.KeyBindingController
PBProvider services.PasteboardProvider
SettingsController *controllers.SettingsController
modUI ucl.Module
}
func NewStandardCommands(
tableService *tables.Service,
state *controllers.State,
readController *controllers.TableReadController,
writeController *controllers.TableWriteController,
exportController *controllers.ExportController,
keyBindingController *controllers.KeyBindingController,
pbProvider services.PasteboardProvider,
settingsController *controllers.SettingsController,
) StandardCommands {
modUI, ckbs := moduleUI(tableService, state, readController)
keyBindingController.SetCustomKeyBindingSource(ckbs)
return StandardCommands{
TableService: tableService,
State: state,
ReadController: readController,
WriteController: writeController,
ExportController: exportController,
KeyBindingController: keyBindingController,
PBProvider: pbProvider,
SettingsController: settingsController,
modUI: modUI,
}
}
var cmdQuitDoc = repl.Doc{
Brief: "Quits dynamo-browse",
Detailed: `
This will quit dynamo-browse immediately, without prompting to apply
any changes.
`,
}
func (sc StandardCommands) cmdQuit(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, tea.Quit)
return nil, nil
}
var cmdTableDoc = repl.Doc{
Brief: "Prompt for table to scan",
Usage: "[NAME]",
Args: []repl.ArgDoc{
{Name: "name", Brief: "Name of the table to scan"},
},
Detailed: `
If called with an argument, it will scan the table with that name and
replace the current result set. If called without an argument, it will
prompt for a table to scan.
This command is intended only for interactive sessions and is not suitable
for scripting. The scan or table prompts will happen asynchronously.
`,
}
func (sc StandardCommands) cmdTable(ctx context.Context, args ucl.CallArgs) (any, error) {
var tableName string
if err := args.Bind(&tableName); err == nil {
commandctrl.PostMsg(ctx, sc.ReadController.ScanTable(tableName))
return nil, nil
}
commandctrl.PostMsg(ctx, sc.ReadController.ListTables(false))
return nil, nil
}
var cmdExportDoc = repl.Doc{
Brief: "Exports a result-set as CSV",
Usage: "FILENAME [-all]",
Args: []repl.ArgDoc{
{Name: "filename", Brief: "Filename to export to"},
{Name: "-all", Brief: "Export all results from the table"},
},
Detailed: `
The fields of the current table view will be treated as the header of the
exported table.
This command is intended only for interactive sessions and is not suitable
for scripting. The export will run asynchronously.
`,
}
func (sc StandardCommands) cmdExport(ctx context.Context, args ucl.CallArgs) (any, error) {
var filename string
if err := args.Bind(&filename); err != nil {
return nil, errors.New("expected filename")
}
opts := controllers.ExportOptions{
AllResults: args.HasSwitch("all"),
}
commandctrl.PostMsg(ctx, sc.ExportController.ExportCSV(filename, opts))
return nil, nil
}
var cmdMarkDoc = repl.Doc{
Brief: "Set the marked items of the current result-set",
Usage: "[WHAT] [-where EXPR]",
Args: []repl.ArgDoc{
{Name: "what", Brief: "Items to mark. Defaults to 'all'"},
{Name: "-where", Brief: "Filter expression select items to mark"},
},
Detailed: `
WHAT can be one of:
- all: Mark all items in the current result-set
- none: Unmark all items in the current result-set
- toggle: Toggle the marked state of all items in the current result-set
`,
}
func (sc StandardCommands) cmdMark(ctx context.Context, args ucl.CallArgs) (any, error) {
var markOp = controllers.MarkOpMark
if args.NArgs() > 0 {
var markOpStr string
if err := args.Bind(&markOpStr); err == nil {
switch markOpStr {
case "all":
markOp = controllers.MarkOpMark
case "none":
markOp = controllers.MarkOpUnmark
case "toggle":
markOp = controllers.MarkOpToggle
default:
return nil, errors.New("unrecognised mark operation")
}
}
}
var whereExpr = ""
if args.HasSwitch("where") {
if err := args.BindSwitch("where", &whereExpr); err != nil {
return nil, err
}
}
commandctrl.PostMsg(ctx, sc.ReadController.Mark(markOp, whereExpr))
return nil, nil
}
var cmdNextPageDoc = repl.Doc{
Brief: "Retrieve and display the next page of the current result-set",
Detailed: `
This command is intended only for interactive sessions and is not suitable
for scripting. Fetching the next page will run asynchronously.
`,
}
func (sc StandardCommands) cmdNextPage(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.ReadController.NextPage())
return nil, nil
}
var cmdDeleteDoc = repl.Doc{
Brief: "Delete the marked items of the current result-set",
Detailed: `
The user will be prompted to confirm the deletion. If approved, the
items will be deleted immediately.
This command is intended only for interactive sessions and is not suitable
for scripting.
`,
}
func (sc StandardCommands) cmdDelete(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.WriteController.DeleteMarked())
return nil, nil
}
var cmdNewItemDoc = repl.Doc{
Brief: "Adds a new item to the current result-set",
Detailed: `
The user will be prompted to enter the values for each required attribute,
such as the partition and sort key. The new item will be commited to the database
upon the next write.
This command is intended only for interactive sessions and is not suitable
for scripting.
`,
}
func (sc StandardCommands) cmdNewItem(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.WriteController.NewItem())
return nil, nil
}
var cmdCloneDoc = repl.Doc{
Brief: "Adds a copy of the selected item as a new item to the current result-set",
Detailed: `
The user will be prompted to enter the partition and sort key. All other
attributes will be cloned from the selected item. The new item will be
commited to the database upon the next write.
This command is intended only for interactive sessions and is not suitable
for scripting.
`,
}
func (sc StandardCommands) cmdClone(ctx context.Context, args ucl.CallArgs) (any, error) {
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.CloneItem(selectedItemIndex))
return nil, nil
}
var cmdSetAttrDoc = repl.Doc{
Brief: "Modify a field value of the selected or marked items",
Usage: "ATTR [TYPE]",
Args: []repl.ArgDoc{
{Name: "attr", Brief: "Attribute to modify"},
{Name: "-S", Brief: "Set attribute to a string"},
{Name: "-N", Brief: "Set attribute to a number"},
{Name: "-BOOL", Brief: "Set attribute to a boolean"},
{Name: "-NULL", Brief: "Set attribute to a null"},
{Name: "-TO", Brief: "Set attribute to the result of an expression"},
},
Detailed: `
The user will be prompted to enter the new value for the attribute.
If the attribute type is not known, then a type will need to be specified.
Otherwise, the type will be unchanged. The modified item will be
commited to the database upon the next write.
`,
}
func (sc StandardCommands) cmdSetAttr(ctx context.Context, args ucl.CallArgs) (any, error) {
var fieldName string
if err := args.Bind(&fieldName); err != nil {
return nil, errors.New("expected field name")
}
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
var itemType = models.UnsetItemType
switch {
case args.HasSwitch("S"):
itemType = models.StringItemType
case args.HasSwitch("N"):
itemType = models.NumberItemType
case args.HasSwitch("BOOL"):
itemType = models.BoolItemType
case args.HasSwitch("NULL"):
itemType = models.NullItemType
case args.HasSwitch("TO"):
itemType = models.ExprValueItemType
}
commandctrl.PostMsg(ctx, sc.WriteController.SetAttributeValue(selectedItemIndex, itemType, fieldName))
return nil, nil
}
var cmdDelAttrDoc = repl.Doc{
Brief: "Remove the field of the selected or marked items",
Usage: "ATTR",
Args: []repl.ArgDoc{
{Name: "attr", Brief: "Attribute to remove"},
},
Detailed: `
The modified item will be commited to the database upon the next write.
`,
}
func (sc StandardCommands) cmdDelAttr(ctx context.Context, args ucl.CallArgs) (any, error) {
var fieldName string
if err := args.Bind(&fieldName); err != nil {
return nil, errors.New("expected field name")
}
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.DeleteAttribute(selectedItemIndex, fieldName))
return nil, nil
}
var cmdPutDoc = repl.Doc{
Brief: "Commit changes to the table",
Detailed: `
This will put all new and modified items.
This command is intended only for interactive sessions and is not suitable
for scripting. The user will be prompted to confirm the changes.
`,
}
func (sc StandardCommands) cmdPut(ctx context.Context, args ucl.CallArgs) (any, error) {
commandctrl.PostMsg(ctx, sc.WriteController.PutItems())
return nil, nil
}
var cmdTouchDoc = repl.Doc{
Brief: "Put the currently selected item",
Detailed: `
This will put the currently selected item, regardless of whether it has been
modified.
This command is intended only for interactive sessions and is not suitable
for scripting. The user will be prompted to confirm the touch.
`,
}
func (sc StandardCommands) cmdTouch(ctx context.Context, args ucl.CallArgs) (any, error) {
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.TouchItem(selectedItemIndex))
return nil, nil
}
var cmdNoisyTouchDoc = repl.Doc{
Brief: "Put the currently selected item by deleting it first",
Detailed: `
This will put the currently selected item, regardless of whether it has been
modified. It does so by removing the item from the table, then adding it back again.
This command is intended only for interactive sessions and is not suitable
for scripting. The user will be prompted to confirm the touch.
`,
}
func (sc StandardCommands) cmdNoisyTouch(ctx context.Context, args ucl.CallArgs) (any, error) {
selectedItemIndex, ok := commandctrl.SelectedItemIndex(ctx)
if !ok {
return nil, errors.New("no item selected")
}
commandctrl.PostMsg(ctx, sc.WriteController.NoisyTouchItem(selectedItemIndex))
return nil, nil
}
var cmdRebindDoc = repl.Doc{
Brief: "Binds a new key to an action",
Usage: "ACTION KEY",
Args: []repl.ArgDoc{
{Name: "action", Brief: "Action to bind"},
{Name: "key", Brief: "Key to bind"},
},
Detailed: `
If the key is already bound to an action, it will be replaced.
The set of actions this command accepts is well-defined. For binding
to arbitrary actions, use the ui:bind command.
`,
}
func (sc StandardCommands) cmdRebind(ctx context.Context, args ucl.CallArgs) (any, error) {
var bindingName, newKey string
if err := args.Bind(&bindingName, &newKey); err != nil {
return nil, errors.New("expected: bindingName newKey")
}
// TODO: should only force if not interactive
commandctrl.PostMsg(ctx, sc.KeyBindingController.Rebind(bindingName, newKey, false))
return nil, nil
}
func (sc StandardCommands) InstOptions() []ucl.InstOption {
return []ucl.InstOption{
ucl.WithModule(moduleRS(sc.TableService, sc.State)),
ucl.WithModule(sc.modUI),
ucl.WithModule(modulePB(sc.PBProvider)),
ucl.WithModule(moduleOpt(sc.SettingsController)),
}
}
func (sc StandardCommands) ConfigureUCL(ucl *ucl.Inst) {
ucl.SetBuiltin("quit", sc.cmdQuit)
ucl.SetBuiltin("table", sc.cmdTable)
ucl.SetBuiltin("export", sc.cmdExport)
ucl.SetBuiltin("mark", sc.cmdMark)
ucl.SetBuiltin("next-page", sc.cmdNextPage)
ucl.SetBuiltin("delete", sc.cmdDelete)
ucl.SetBuiltin("new-item", sc.cmdNewItem)
ucl.SetBuiltin("clone", sc.cmdClone)
ucl.SetBuiltin("set-attr", sc.cmdSetAttr)
ucl.SetBuiltin("del-attr", sc.cmdDelAttr)
ucl.SetBuiltin("put", sc.cmdPut)
ucl.SetBuiltin("touch", sc.cmdTouch)
ucl.SetBuiltin("noisy-touch", sc.cmdNoisyTouch)
ucl.SetBuiltin("rebind", sc.cmdRebind)
// Aliases
ucl.SetBuiltin("sa", sc.cmdSetAttr)
ucl.SetBuiltin("da", sc.cmdDelAttr)
ucl.SetBuiltin("np", sc.cmdNextPage)
ucl.SetBuiltin("w", sc.cmdPut)
ucl.SetBuiltin("q", sc.cmdQuit)
ucl.SetPseudoVar("resultset", resultSetPVar{sc.State, sc.ReadController})
ucl.SetPseudoVar("table", tablePVar{sc.State})
ucl.SetPseudoVar("item", itemPVar{sc.State})
}
func (sc StandardCommands) RunPrelude(ctx context.Context, ucl *ucl.Inst) error {
_, err := ucl.EvalString(ctx, uclPrelude)
return err
}
const uclPrelude = `
ui:command unmark { mark none }
ui:command set-opt { |n k| opt:set $n $k }
`

View file

@ -0,0 +1,218 @@
package cmdpacks_test
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl/cmdpacks"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/dynamo"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/inputhistorystore"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/pasteboardprovider"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/settingstore"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/providers/workspacestore"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/inputhistory"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs"
keybindings_service "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings"
"github.com/lmika/dynamo-browse/test/testdynamo"
"github.com/lmika/dynamo-browse/test/testworkspace"
bus "github.com/lmika/events"
"github.com/stretchr/testify/assert"
"testing"
)
func TestStdCmds_Mark(t *testing.T) {
tests := []struct {
descr string
cmd string
wantMarks []bool
}{
{descr: "mark default", cmd: "mark", wantMarks: []bool{true, true, true}},
{descr: "mark all", cmd: "mark all", wantMarks: []bool{true, true, true}},
{descr: "mark none", cmd: "mark none", wantMarks: []bool{false, false, false}},
{descr: "mark where", cmd: `mark -where 'sk="222"'`, wantMarks: []bool{false, true, false}},
{descr: "mark toggle", cmd: "mark ; mark toggle", wantMarks: []bool{false, false, false}},
}
for _, tt := range tests {
t.Run(tt.descr, func(t *testing.T) {
svc := newService(t)
_, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd)
assert.NoError(t, err)
for i, want := range tt.wantMarks {
assert.Equal(t, want, svc.State.ResultSet().Marked(i))
}
})
}
}
type testDataGenerator func() []testdynamo.TestData
type services struct {
CommandController *commandctrl.CommandController
SelItemIndex int
State *controllers.State
settingStore *settingstore.SettingStore
table string
testDataGenerator testDataGenerator
testData []testdynamo.TestData
}
type serviceOpt func(*services)
func withDataGenerator(tg testDataGenerator) serviceOpt {
return func(s *services) {
s.testDataGenerator = tg
}
}
func withTable(table string) serviceOpt {
return func(s *services) {
s.table = table
}
}
func withDefaultLimit(limit int) serviceOpt {
return func(s *services) {
s.settingStore.SetDefaultLimit(limit)
}
}
func newService(t *testing.T, opts ...serviceOpt) *services {
ws := testworkspace.New(t)
resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws)
settingStore := settingstore.New(ws)
inputHistoryStore := inputhistorystore.NewInputHistoryStore(ws)
workspaceService := viewsnapshot.NewService(resultSetSnapshotStore)
itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer())
inputHistoryService := inputhistory.New(inputHistoryStore)
s := &services{
table: "service-test-data",
settingStore: settingStore,
testDataGenerator: normalTestData,
}
for _, opt := range opts {
opt(s)
}
s.testData = s.testDataGenerator()
client := testdynamo.SetupTestTable(t, s.testData)
provider := dynamo.NewProvider(client)
service := tables.NewService(provider, settingStore)
eventBus := bus.New()
state := controllers.NewState()
jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true)
readController := controllers.NewTableReadController(
state,
service,
workspaceService,
itemRendererService,
jobsController,
inputHistoryService,
eventBus,
pasteboardprovider.NilProvider{},
s.table,
)
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
settingsController := controllers.NewSettingsController(settingStore, eventBus)
columnsController := controllers.NewColumnsController(readController, eventBus)
exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{})
keyBindingService := keybindings_service.NewService(keybindings.Default())
keyBindingController := controllers.NewKeyBindingController(keyBindingService, nil)
commandController, _ := commandctrl.NewCommandController(inputHistoryService,
cmdpacks.NewStandardCommands(
service,
state,
readController,
writeController,
exportController,
keyBindingController,
pasteboardprovider.NilProvider{},
settingsController,
),
)
s.State = state
s.CommandController = commandController
commandController.SetUIStateProvider(s)
readController.Init()
return s
}
func (s *services) SelectedItemIndex() int {
return s.SelItemIndex
}
func (s *services) SetSelectedItemIndex(newIdx int) tea.Msg {
s.SelItemIndex = newIdx
return nil
}
func normalTestData() []testdynamo.TestData {
return []testdynamo.TestData{
{
TableName: "service-test-data",
Data: []map[string]interface{}{
{
"pk": "abc",
"sk": "111",
"alpha": "This is some value",
},
{
"pk": "abc",
"sk": "222",
"alpha": "This is another some value",
"beta": 1231,
},
{
"pk": "bbb",
"sk": "131",
"beta": 2468,
"gamma": "foobar",
},
},
},
}
}
func largeTestData() []testdynamo.TestData {
return []testdynamo.TestData{
{
TableName: "large-table",
Data: genRow(50, func(i int) map[string]interface{} {
return map[string]interface{}{
"pk": fmt.Sprint(i),
"sk": fmt.Sprint(i),
"alpha": fmt.Sprintf("row %v", i),
}
}),
},
}
}
func genRow(count int, mapFn func(int) map[string]interface{}) []map[string]interface{} {
result := make([]map[string]interface{}, count)
for i := 0; i < count; i++ {
result[i] = mapFn(i)
}
return result
}

View file

@ -1,15 +1,17 @@
package commandctrl
import (
"bufio"
"bytes"
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/pkg/errors"
"log"
"os"
"path/filepath"
"strings"
"ucl.lmika.dev/ucl"
"ucl.lmika.dev/ucl/builtins"
"github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/shellwords"
@ -17,19 +19,58 @@ import (
const commandsCategory = "commands"
type cmdMessage struct {
cmd string
}
type CommandController struct {
uclInst *ucl.Inst
historyProvider IterProvider
commandList *CommandList
lookupExtensions []CommandLookupExtension
completionProvider CommandCompletionProvider
uiStateProvider UIStateProvider
cmdChan chan cmdMessage
msgChan chan tea.Msg
interactive bool
}
func NewCommandController(historyProvider IterProvider) *CommandController {
return &CommandController{
func NewCommandController(historyProvider IterProvider, pkgs ...CommandPack) (*CommandController, error) {
cc := &CommandController{
historyProvider: historyProvider,
commandList: nil,
lookupExtensions: nil,
cmdChan: make(chan cmdMessage),
msgChan: make(chan tea.Msg),
interactive: true,
}
options := []ucl.InstOption{
ucl.WithOut(ucl.LineHandler(cc.printLine)),
ucl.WithModule(builtins.OS()),
ucl.WithModule(builtins.FS(nil)),
}
for _, pkg := range pkgs {
options = append(options, pkg.InstOptions()...)
}
cc.uclInst = ucl.New(options...)
for _, pkg := range pkgs {
pkg.ConfigureUCL(cc.uclInst)
}
execCtx := execContext{ctrl: cc}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
for _, pkg := range pkgs {
if err := pkg.RunPrelude(ctx, cc.uclInst); err != nil {
return nil, err
}
}
go cc.cmdLooper()
return cc, nil
}
func (c *CommandController) AddCommands(ctx *CommandList) {
@ -37,6 +78,16 @@ func (c *CommandController) AddCommands(ctx *CommandList) {
c.commandList = ctx
}
func (c *CommandController) StartMessageSender(msgSender func(tea.Msg)) {
for msg := range c.msgChan {
msgSender(msg)
}
}
func (c *CommandController) SetUIStateProvider(provider UIStateProvider) {
c.uiStateProvider = provider
}
func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) {
c.lookupExtensions = append(c.lookupExtensions, ext)
}
@ -83,29 +134,65 @@ func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Ms
return nil
}
tokens := shellwords.Split(input)
command := c.lookupCommand(tokens[0])
if command == nil {
return events.Error(errors.New("no such command: " + tokens[0]))
select {
case c.cmdChan <- cmdMessage{cmd: input}:
// good
default:
return events.Error(errors.New("command currently running"))
}
return command(ctx, tokens[1:])
return nil
}
func (c *CommandController) Alias(commandName string, aliasArgs []string) Command {
return func(ctx ExecContext, args []string) tea.Msg {
func (c *CommandController) ExecuteAndWait(ctx context.Context, commandInput string) (any, error) {
return c.uclInst.EvalString(ctx, commandInput)
}
func (c *CommandController) Invoke(invokable ucl.Invokable, args []any) (msg tea.Msg) {
execCtx := execContext{ctrl: c}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
res, err := invokable.Invoke(ctx, args)
if err != nil {
msg = events.Error(err)
} else if res != nil {
msg = events.StatusMsg(fmt.Sprint(res))
}
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
return msg
}
func (c *CommandController) cmdLooper() {
execCtx := execContext{ctrl: c}
ctx := context.WithValue(context.Background(), commandCtlKey, &execCtx)
for {
select {
case cmdChan := <-c.cmdChan:
res, err := c.ExecuteAndWait(ctx, cmdChan.cmd)
if err != nil {
c.postMessage(events.Error(err))
} else if res != nil {
c.postMessage(events.StatusMsg(fmt.Sprint(res)))
}
if execCtx.requestRefresh {
c.postMessage(events.ResultSetUpdated{})
}
}
}
}
func (c *CommandController) Alias(commandName string) Command {
return func(ctx ExecContext, args ucl.CallArgs) tea.Msg {
command := c.lookupCommand(commandName)
if command == nil {
return events.Error(errors.New("no such command: " + commandName))
}
var allArgs []string
if len(aliasArgs) > 0 {
allArgs = append(append([]string{}, aliasArgs...), args...)
} else {
allArgs = args
}
return command(ctx, allArgs)
return command(ctx, args)
}
}
@ -124,39 +211,114 @@ func (c *CommandController) lookupCommand(name string) Command {
return nil
}
func (c *CommandController) ExecuteFile(filename string) error {
baseFilename := filepath.Base(filename)
func (c *CommandController) LoadExtensions(ctx context.Context, baseDirs []string) error {
log.Printf("loading extensions: %v", baseDirs)
for _, baseDir := range baseDirs {
baseDir = os.ExpandEnv(baseDir)
descendIntoSubDirs := !strings.HasSuffix(baseDir, ".")
if rcFile, err := os.ReadFile(filename); err == nil {
if err := c.executeFile(rcFile, baseFilename); err != nil {
return errors.Wrapf(err, "error executing %v", filename)
if stat, err := os.Stat(baseDir); err != nil {
if os.IsNotExist(err) {
continue
}
return err
} else if !stat.IsDir() {
continue
}
log.Printf("walking %v", baseDir)
if err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if !descendIntoSubDirs && path != baseDir {
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(info.Name(), ".ucl") {
if err := c.ExecuteFile(ctx, path); err != nil {
log.Println(err)
}
log.Printf("loaded %v\n", path)
}
return nil
}); err != nil {
return err
}
} else {
return errors.Wrapf(err, "error loading %v", filename)
}
return nil
}
func (c *CommandController) executeFile(file []byte, filename string) error {
scnr := bufio.NewScanner(bytes.NewReader(file))
func (c *CommandController) ExecuteFile(ctx context.Context, filename string) error {
oldInteractive := c.interactive
c.interactive = false
defer func() {
c.interactive = oldInteractive
}()
lineNo := 0
for scnr.Scan() {
lineNo++
line := strings.TrimSpace(scnr.Text())
if line == "" {
continue
} else if line[0] == '#' {
continue
}
baseFilename := filepath.Base(filename)
msg := c.execute(ExecContext{FromFile: true}, line)
switch m := msg.(type) {
case events.ErrorMsg:
log.Printf("%v:%v: error - %v", filename, lineNo, m.Error())
case events.StatusMsg:
log.Printf("%v:%v: %v", filename, lineNo, string(m))
execCtx := execContext{ctrl: c}
ctx = context.WithValue(context.Background(), commandCtlKey, &execCtx)
if rcFile, err := os.ReadFile(filename); err == nil {
if err := c.executeFile(ctx, rcFile); err != nil {
return errors.Wrapf(err, "error executing %v", baseFilename)
}
} else {
return errors.Wrapf(err, "error loading %v", baseFilename)
}
return scnr.Err()
return nil
}
func (c *CommandController) executeFile(ctx context.Context, file []byte) error {
if _, err := c.uclInst.Eval(ctx, bytes.NewReader(file), ucl.WithSubEnv()); err != nil {
return err
}
return nil
}
func (c *CommandController) Inst() *ucl.Inst {
return c.uclInst
}
func (c *CommandController) cmdInvoker(ctx context.Context, name string, args ucl.CallArgs) (any, error) {
command := c.lookupCommand(name)
if command == nil {
return nil, errors.New("no such command: " + name)
}
res := command(ExecContext{}, args)
if errMsg, isErrMsg := res.(events.ErrorMsg); isErrMsg {
return nil, errMsg
}
return teaMsgWrapper{res}, nil
}
func (c *CommandController) printLine(s string) {
if c.msgChan == nil || !c.interactive {
log.Println(s)
return
}
select {
case c.msgChan <- events.StatusMsg(s):
default:
log.Println(s)
}
}
func (c *CommandController) postMessage(msg tea.Msg) {
if c.msgChan == nil {
return
}
c.msgChan <- msg
}
type teaMsgWrapper struct {
msg tea.Msg
}

View file

@ -12,7 +12,8 @@ import (
func TestCommandController_Prompt(t *testing.T) {
t.Run("prompt user for a command", func(t *testing.T) {
cmd := commandctrl.NewCommandController(mockIterProvider{})
cmd, err := commandctrl.NewCommandController(mockIterProvider{})
assert.NoError(t, err)
res := cmd.Prompt()

View file

@ -0,0 +1,63 @@
package commandctrl
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"ucl.lmika.dev/ucl"
)
type commandCtlKeyType struct{}
var commandCtlKey = commandCtlKeyType{}
type execContext struct {
ctrl *CommandController
requestRefresh bool
}
func PostMsg(ctx context.Context, msg tea.Msg) {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if ok {
cmdCtl.ctrl.postMessage(msg)
}
}
func SelectedItemIndex(ctx context.Context) (int, bool) {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return 0, false
}
return cmdCtl.ctrl.uiStateProvider.SelectedItemIndex(), true
}
func SetSelectedItemIndex(ctx context.Context, newIdx int) tea.Msg {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return nil
}
return cmdCtl.ctrl.uiStateProvider.SetSelectedItemIndex(newIdx)
}
func GetInvoker(ctx context.Context) Invoker {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return nil
}
return cmdCtl.ctrl
}
func QueueRefresh(ctx context.Context) {
cmdCtl, ok := ctx.Value(commandCtlKey).(*execContext)
if !ok {
return
}
cmdCtl.requestRefresh = true
}
type Invoker interface {
Invoke(invokable ucl.Invokable, args []any) tea.Msg
Inst() *ucl.Inst
}

View file

@ -2,9 +2,15 @@ package commandctrl
import (
"context"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
)
type IterProvider interface {
Iter(ctx context.Context, category string) services.HistoryProvider
}
type UIStateProvider interface {
SelectedItemIndex() int
SetSelectedItemIndex(newIdx int) tea.Msg
}

View file

@ -0,0 +1,12 @@
package commandctrl
import (
"context"
"ucl.lmika.dev/ucl"
)
type CommandPack interface {
InstOptions() []ucl.InstOption
ConfigureUCL(ucl *ucl.Inst)
RunPrelude(ctx context.Context, ucl *ucl.Inst) error
}

View file

@ -1,11 +1,14 @@
package commandctrl
import tea "github.com/charmbracelet/bubbletea"
import (
tea "github.com/charmbracelet/bubbletea"
"ucl.lmika.dev/ucl"
)
type Command func(ctx ExecContext, args []string) tea.Msg
type Command func(ctx ExecContext, args ucl.CallArgs) tea.Msg
func NoArgCommand(cmd tea.Cmd) Command {
return func(ctx ExecContext, args []string) tea.Msg {
return func(ctx ExecContext, args ucl.CallArgs) tea.Msg {
return cmd()
}
}

View file

@ -0,0 +1,5 @@
package events
type ResultSetUpdated struct {
StatusMessage string
}

View file

@ -81,14 +81,6 @@ type PromptForTableMsg struct {
OnSelected func(tableName string) tea.Msg
}
type ResultSetUpdated struct {
statusMessage string
}
func (rs ResultSetUpdated) StatusMessage() string {
return rs.statusMessage
}
type ShowColumnOverlay struct{}
type HideColumnOverlay struct{}

View file

@ -107,7 +107,7 @@ func (c *ExportController) ExportCSVToClipboard() tea.Msg {
if err := c.pasteboardProvider.WriteText(bts.Bytes()); err != nil {
return events.Error(err)
}
return nil
return events.StatusMsg("Table copied to clipboard")
}
// TODO: this really needs to be a service!

View file

@ -20,6 +20,10 @@ func NewKeyBindingController(service *keybindings.Service, customBindingSource C
}
}
func (kb *KeyBindingController) SetCustomKeyBindingSource(customBindingSource CustomKeyBindingSource) {
kb.customBindingSource = customBindingSource
}
func (kb *KeyBindingController) Rebind(bindingName string, newKey string, force bool) tea.Msg {
existingBinding := kb.findExistingBinding(newKey)
if existingBinding == "" {

View file

@ -1,281 +0,0 @@
package controllers
import (
"context"
"fmt"
"log"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
bus "github.com/lmika/events"
"github.com/pkg/errors"
)
type ScriptController struct {
scriptManager *scriptmanager.Service
tableReadController *TableReadController
jobController *JobsController
settingsController *SettingsController
eventBus *bus.Bus
sendMsg func(msg tea.Msg)
}
func NewScriptController(
scriptManager *scriptmanager.Service,
tableReadController *TableReadController,
jobController *JobsController,
settingsController *SettingsController,
eventBus *bus.Bus,
) *ScriptController {
sc := &ScriptController{
scriptManager: scriptManager,
tableReadController: tableReadController,
jobController: jobController,
settingsController: settingsController,
eventBus: eventBus,
}
sessionImpl := &sessionImpl{sc: sc, lastSelectedItemIndex: -1}
scriptManager.SetIFaces(scriptmanager.Ifaces{
UI: &uiImpl{sc: sc},
Session: sessionImpl,
})
sessionImpl.subscribeToEvents(eventBus)
// Setup event handling when settings have changed
eventBus.On(BusEventSettingsUpdated, func(name, value string) {
if !strings.HasPrefix(name, "script.") {
return
}
sc.Init()
})
return sc
}
func (sc *ScriptController) Init() {
if lookupPaths, err := sc.settingsController.settings.ScriptLookupFS(); err == nil {
sc.scriptManager.SetLookupPaths(lookupPaths)
} else {
log.Printf("warn: script lookup paths are invalid: %v", err)
}
}
func (sc *ScriptController) SetMessageSender(sendMsg func(msg tea.Msg)) {
sc.sendMsg = sendMsg
}
func (sc *ScriptController) LoadScript(filename string) tea.Msg {
ctx := context.Background()
plugin, err := sc.scriptManager.LoadScript(ctx, filename)
if err != nil {
return events.Error(err)
}
return events.StatusMsg(fmt.Sprintf("Script '%v' loaded", plugin.Name()))
}
func (sc *ScriptController) RunScript(filename string) tea.Msg {
ctx := context.Background()
if err := sc.scriptManager.StartAdHocScript(ctx, filename, sc.waitAndPrintScriptError()); err != nil {
return events.Error(err)
}
return nil
}
func (sc *ScriptController) waitAndPrintScriptError() chan error {
errChan := make(chan error)
go func() {
if err := <-errChan; err != nil {
sc.sendMsg(events.Error(err))
}
}()
return errChan
}
func (sc *ScriptController) LookupCommand(name string) commandctrl.Command {
cmd := sc.scriptManager.LookupCommand(name)
if cmd == nil {
return nil
}
return func(execCtx commandctrl.ExecContext, args []string) tea.Msg {
errChan := sc.waitAndPrintScriptError()
ctx := context.Background()
if err := cmd.Invoke(ctx, args, errChan); err != nil {
return events.Error(err)
}
return nil
}
}
type uiImpl struct {
sc *ScriptController
}
func (u uiImpl) PrintMessage(ctx context.Context, msg string) {
u.sc.sendMsg(events.StatusMsg(msg))
}
func (u uiImpl) Prompt(ctx context.Context, msg string) chan string {
resultChan := make(chan string)
u.sc.sendMsg(events.PromptForInputMsg{
Prompt: msg,
OnDone: func(value string) tea.Msg {
resultChan <- value
return nil
},
OnCancel: func() tea.Msg {
close(resultChan)
return nil
},
})
return resultChan
}
type sessionImpl struct {
sc *ScriptController
lastSelectedItemIndex int
}
func (s *sessionImpl) subscribeToEvents(bus *bus.Bus) {
bus.On("ui.new-item-selected", func(rs *models.ResultSet, itemIndex int) {
s.lastSelectedItemIndex = itemIndex
})
}
func (s *sessionImpl) SelectedItemIndex(ctx context.Context) int {
return s.lastSelectedItemIndex
}
func (s *sessionImpl) ResultSet(ctx context.Context) *models.ResultSet {
return s.sc.tableReadController.state.ResultSet()
}
func (s *sessionImpl) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) {
state := s.sc.tableReadController.state
msg := s.sc.tableReadController.setResultSetAndFilter(newResultSet, state.filter, true, resultSetUpdateScript)
s.sc.sendMsg(msg)
}
func (s *sessionImpl) Query(ctx context.Context, query string, opts scriptmanager.QueryOptions) (*models.ResultSet, error) {
// Parse the query
expr, err := queryexpr.Parse(query)
if err != nil {
return nil, err
}
if opts.NamePlaceholders != nil {
expr = expr.WithNameParams(opts.NamePlaceholders)
}
if opts.ValuePlaceholders != nil {
expr = expr.WithValueParams(opts.ValuePlaceholders)
}
if opts.IndexName != "" {
expr = expr.WithIndex(opts.IndexName)
}
return s.sc.doQuery(ctx, expr, opts)
}
func (s *ScriptController) doQuery(ctx context.Context, expr *queryexpr.QueryExpr, opts scriptmanager.QueryOptions) (*models.ResultSet, error) {
// Get the table info
var (
tableInfo *models.TableInfo
err error
)
tableName := opts.TableName
currentResultSet := s.tableReadController.state.ResultSet()
if tableName != "" {
// Table specified. If it's the same as the current table, then use the existing table info
if currentResultSet != nil && currentResultSet.TableInfo.Name == tableName {
tableInfo = currentResultSet.TableInfo
}
// Otherwise, describe the table
tableInfo, err = s.tableReadController.tableService.Describe(ctx, tableName)
if err != nil {
return nil, errors.Wrapf(err, "cannot describe table '%v'", tableName)
}
} else {
// Table not specified. Use the existing table, if any
if currentResultSet == nil {
return nil, errors.New("no table currently selected")
}
tableInfo = currentResultSet.TableInfo
}
newResultSet, err := s.tableReadController.tableService.ScanOrQuery(ctx, tableInfo, expr, nil)
if err != nil {
return nil, err
}
return newResultSet, nil
}
func (sc *ScriptController) CustomKeyCommand(key string) tea.Cmd {
_, cmd := sc.scriptManager.LookupKeyBinding(key)
if cmd == nil {
return nil
}
return func() tea.Msg {
errChan := sc.waitAndPrintScriptError()
ctx := context.Background()
if err := cmd.Invoke(ctx, nil, errChan); err != nil {
return events.Error(err)
}
return nil
}
}
func (sc *ScriptController) Rebind(bindingName string, newKey string) error {
return sc.scriptManager.RebindKeyBinding(bindingName, newKey)
}
func (sc *ScriptController) LookupBinding(theKey string) string {
bindingName, _ := sc.scriptManager.LookupKeyBinding(theKey)
return bindingName
}
func (sc *ScriptController) UnbindKey(key string) {
sc.scriptManager.UnbindKey(key)
}
func (c *ScriptController) LookupRelatedItems(idx int) (res tea.Msg) {
rs := c.tableReadController.state.ResultSet()
relItems, err := c.scriptManager.RelatedItemOfItem(context.Background(), rs, idx)
if err != nil {
return events.Error(err)
} else if len(relItems) == 0 {
return events.StatusMsg("No related items available")
}
return ShowRelatedItemsOverlay{
Items: relItems,
OnSelected: func(item relitems.RelatedItem) tea.Msg {
if item.OnSelect != nil {
return item.OnSelect()
}
return NewJob(c.jobController, "Running query…", func(ctx context.Context) (*models.ResultSet, error) {
return c.doQuery(ctx, item.Query, scriptmanager.QueryOptions{
TableName: item.Table,
})
}).OnDone(func(rs *models.ResultSet) tea.Msg {
return c.tableReadController.setResultSetAndFilter(rs, "", true, resultSetUpdateQuery)
}).Submit()
},
}
}

View file

@ -1,192 +0,0 @@
package controllers_test
import (
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/stretchr/testify/assert"
)
func TestScriptController_RunScript(t *testing.T) {
t.Run("should execute scripts successfully", func(t *testing.T) {
srv := newService(t, serviceConfig{
scriptFS: testScriptFile(t, "test.tm", `
ui.print("Hello world")
`),
})
msg := srv.scriptController.RunScript("test.tm")
assert.Nil(t, msg)
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.Equal(t, events.StatusMsg("Hello world"), srv.msgSender.msgs[0])
})
t.Run("session.result_set", func(t *testing.T) {
t.Run("should return current result set if not-nil", func(t *testing.T) {
srv := newService(t, serviceConfig{
tableName: "alpha-table",
scriptFS: testScriptFile(t, "test.tm", `
rs := session.result_set()
ui.print(rs.length)
`),
})
invokeCommand(t, srv.readController.Init())
msg := srv.scriptController.RunScript("test.tm")
assert.Nil(t, msg)
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.Equal(t, events.StatusMsg("3"), srv.msgSender.msgs[0])
})
})
t.Run("session.query", func(t *testing.T) {
t.Run("should run query against current table", func(t *testing.T) {
srv := newService(t, serviceConfig{
tableName: "alpha-table",
scriptFS: testScriptFile(t, "test.tm", `
rs := session.query('pk="abc"')
ui.print(rs.length)
`),
})
invokeCommand(t, srv.readController.Init())
msg := srv.scriptController.RunScript("test.tm")
assert.Nil(t, msg)
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.Equal(t, events.StatusMsg("2"), srv.msgSender.msgs[0])
})
t.Run("should run query against another table", func(t *testing.T) {
srv := newService(t, serviceConfig{
tableName: "alpha-table",
scriptFS: testScriptFile(t, "test.tm", `
rs := session.query('pk!="abc"', { table: "count-to-30" })
ui.print(rs.length)
`),
})
invokeCommand(t, srv.readController.Init())
msg := srv.scriptController.RunScript("test.tm")
assert.Nil(t, msg)
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.Equal(t, events.StatusMsg("30"), srv.msgSender.msgs[0])
})
})
t.Run("session.set_result_set", func(t *testing.T) {
t.Run("should set the result set from the result of a query", func(t *testing.T) {
srv := newService(t, serviceConfig{
tableName: "alpha-table",
scriptFS: testScriptFile(t, "test.tm", `
rs := session.query('pk="abc"')
session.set_result_set(rs)
`),
})
invokeCommand(t, srv.readController.Init())
msg := srv.scriptController.RunScript("test.tm")
assert.Nil(t, msg)
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.IsType(t, controllers.NewResultSet{}, srv.msgSender.msgs[0])
})
t.Run("changed attributes of the result set should show up as modified", func(t *testing.T) {
srv := newService(t, serviceConfig{
tableName: "alpha-table",
scriptFS: testScriptFile(t, "test.tm", `
rs := session.query('pk="abc"')
rs[0].set_attr("pk", "131")
session.set_result_set(rs)
`),
})
invokeCommand(t, srv.readController.Init())
msg := srv.scriptController.RunScript("test.tm")
assert.Nil(t, msg)
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.IsType(t, controllers.NewResultSet{}, srv.msgSender.msgs[0])
assert.Equal(t, "131", srv.state.ResultSet().Items()[0]["pk"].(*types.AttributeValueMemberS).Value)
assert.True(t, srv.state.ResultSet().IsDirty(0))
})
})
}
func TestScriptController_LookupCommand(t *testing.T) {
t.Run("should schedule the script on a separate go-routine", func(t *testing.T) {
scenarios := []struct {
descr string
command string
expectedOutput string
}{
{descr: "command with arg", command: "mycommand \"test name\"", expectedOutput: "Hello, test name"},
{descr: "command no arg", command: "mycommand", expectedOutput: "Hello, nil value"},
}
for _, scenario := range scenarios {
t.Run(scenario.descr, func(t *testing.T) {
srv := newService(t, serviceConfig{
tableName: "alpha-table",
scriptFS: testScriptFile(t, "test.tm", `
ext.command("mycommand", func(name = "nil value") {
ui.print(sprintf("Hello, %v", name))
})
`),
})
invokeCommand(t, srv.scriptController.LoadScript("test.tm"))
invokeCommand(t, srv.commandController.Execute(scenario.command))
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.Equal(t, events.StatusMsg(scenario.expectedOutput), srv.msgSender.msgs[0])
})
}
})
t.Run("should only allow one script to run at a time", func(t *testing.T) {
srv := newService(t, serviceConfig{
tableName: "alpha-table",
scriptFS: testScriptFile(t, "test.tm", `
ext.command("mycommand", func() {
time.sleep(1.5)
ui.print("Done my thing")
})
`),
})
invokeCommand(t, srv.scriptController.LoadScript("test.tm"))
invokeCommand(t, srv.commandController.Execute(`mycommand`))
invokeCommandExpectingError(t, srv.commandController.Execute(`mycommand`))
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
assert.Len(t, srv.msgSender.msgs, 1)
assert.Equal(t, events.StatusMsg("Done my thing"), srv.msgSender.msgs[0])
})
}

View file

@ -29,6 +29,14 @@ func (s *State) Filter() string {
return s.filter
}
func (s *State) SetResultSet(resultSet *models.ResultSet) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.resultSet = resultSet
s.filter = ""
}
func (s *State) withResultSet(rs func(*models.ResultSet)) {
s.mutex.Lock()
defer s.mutex.Unlock()

View file

@ -61,7 +61,6 @@ type TableReadController struct {
tableName string
loadFromLastView bool
pasteboardProvider services.PasteboardProvider
relatedItemSupplier RelatedItemSupplier
// state
mutex *sync.Mutex
@ -77,7 +76,6 @@ func NewTableReadController(
inputHistoryService *inputhistory.Service,
eventBus *bus.Bus,
pasteboardProvider services.PasteboardProvider,
relatedItemSupplier RelatedItemSupplier,
tableName string,
) *TableReadController {
return &TableReadController{
@ -90,7 +88,6 @@ func NewTableReadController(
eventBus: eventBus,
tableName: tableName,
pasteboardProvider: pasteboardProvider,
relatedItemSupplier: relatedItemSupplier,
mutex: new(sync.Mutex),
}
}
@ -182,6 +179,10 @@ func (c *TableReadController) PromptForQuery() tea.Msg {
}
}
func (c *TableReadController) RunQuery(q *queryexpr.QueryExpr, table *models.TableInfo) tea.Msg {
return c.runQuery(table, q, "", true, nil)
}
func (c *TableReadController) runQuery(
tableInfo *models.TableInfo,
query *queryexpr.QueryExpr,
@ -291,6 +292,12 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet,
return c.state.buildNewResultSetMessage("")
}
func (c *TableReadController) SetResultSet(resultSet *models.ResultSet) tea.Msg {
c.state.setResultSetAndFilter(resultSet, "")
c.eventBus.Fire(newResultSetEvent, resultSet, resultSetUpdateScript)
return c.state.buildNewResultSetMessage("")
}
func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg {
var (
whereExpr *queryexpr.QueryExpr
@ -333,27 +340,31 @@ func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg {
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (c *TableReadController) Filter() tea.Msg {
func (c *TableReadController) PromptForFilter() tea.Msg {
return events.PromptForInputMsg{
Prompt: "filter: ",
History: c.inputHistoryService.Iter(context.Background(), filterInputHistoryCategory),
OnDone: func(value string) tea.Msg {
resultSet := c.state.ResultSet()
if resultSet == nil {
return events.StatusMsg("Result-set is nil")
}
return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) {
newResultSet := c.tableService.Filter(resultSet, value)
return newResultSet, nil
}).OnEither(c.handleResultSetFromJobResult(value, true, false, resultSetUpdateFilter)).Submit()
return c.Filter(value)
},
}
}
func (c *TableReadController) Filter(value string) tea.Msg {
resultSet := c.state.ResultSet()
if resultSet == nil {
return events.StatusMsg("Result-set is nil")
}
return NewJob(c.jobController, "Applying Filter…", func(ctx context.Context) (*models.ResultSet, error) {
newResultSet := c.tableService.Filter(resultSet, value)
return newResultSet, nil
}).OnEither(c.handleResultSetFromJobResult(value, true, false, resultSetUpdateFilter)).Submit()
}
func (c *TableReadController) handleResultSetFromJobResult(
filter string,
pushbackStack, errIfEmpty bool,

View file

@ -44,7 +44,7 @@ func (twc *TableWriteController) ToggleMark(idx int) tea.Msg {
resultSet.SetMark(idx, !resultSet.Marked(idx))
})
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (twc *TableWriteController) NewItem() tea.Msg {
@ -148,7 +148,7 @@ func (twc *TableWriteController) setStringValue(idx int, attr *queryexpr.QueryEx
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -181,7 +181,7 @@ func (twc *TableWriteController) setToExpressionValue(idx int, attr *queryexpr.Q
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -205,7 +205,7 @@ func (twc *TableWriteController) setNumberValue(idx int, attr *queryexpr.QueryEx
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -234,7 +234,7 @@ func (twc *TableWriteController) setBoolValue(idx int, attr *queryexpr.QueryExpr
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}
@ -255,7 +255,7 @@ func (twc *TableWriteController) setNullValue(idx int, attr *queryexpr.QueryExpr
}); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg {
@ -291,7 +291,7 @@ func (twc *TableWriteController) DeleteAttribute(idx int, key string) tea.Msg {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
}
func (twc *TableWriteController) PutItems() tea.Msg {
@ -351,8 +351,8 @@ func (twc *TableWriteController) PutItems() tea.Msg {
}
return rs, nil
}).OnDone(func(rs *models.ResultSet) tea.Msg {
return ResultSetUpdated{
statusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"),
return events.ResultSetUpdated{
StatusMessage: applyToN("", len(itemsToPut), "item", "item", " put to table"),
}
}).Submit()
},
@ -379,7 +379,7 @@ func (twc *TableWriteController) TouchItem(idx int) tea.Msg {
if err := twc.tableService.PutItemAt(context.Background(), resultSet, idx); err != nil {
return events.Error(err)
}
return ResultSetUpdated{}
return events.ResultSetUpdated{}
},
}
}

View file

@ -15,7 +15,6 @@ import (
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/inputhistory"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/tables"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/viewsnapshot"
"github.com/lmika/dynamo-browse/test/testdynamo"
@ -587,7 +586,6 @@ type services struct {
settingsController *controllers.SettingsController
columnsController *controllers.ColumnsController
exportController *controllers.ExportController
scriptController *controllers.ScriptController
commandController *commandctrl.CommandController
}
@ -607,7 +605,6 @@ func newService(t *testing.T, cfg serviceConfig) *services {
workspaceService := viewsnapshot.NewService(resultSetSnapshotStore)
itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer())
scriptService := scriptmanager.New()
inputHistoryService := inputhistory.New(inputHistoryStore)
client := testdynamo.SetupTestTable(t, testData)
@ -627,17 +624,14 @@ func newService(t *testing.T, cfg serviceConfig) *services {
inputHistoryService,
eventBus,
pasteboardprovider.NilProvider{},
nil,
cfg.tableName,
)
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
settingsController := controllers.NewSettingsController(settingStore, eventBus)
columnsController := controllers.NewColumnsController(readController, eventBus)
exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{})
scriptController := controllers.NewScriptController(scriptService, readController, jobsController, settingsController, eventBus)
commandController := commandctrl.NewCommandController(inputHistoryService)
commandController.AddCommandLookupExtension(scriptController)
commandController, _ := commandctrl.NewCommandController(inputHistoryService)
if cfg.isReadOnly {
if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil {
@ -651,12 +645,7 @@ func newService(t *testing.T, cfg serviceConfig) *services {
}
msgSender := &msgSender{}
scriptController.Init()
jobsController.SetMessageSender(msgSender.send)
scriptController.SetMessageSender(msgSender.send)
// Initting will setup the default script lookup paths, so revert them to the test ones
scriptService.SetLookupPaths([]fs.FS{cfg.scriptFS})
return &services{
state: state,
@ -666,7 +655,6 @@ func newService(t *testing.T, cfg serviceConfig) *services {
settingsController: settingsController,
columnsController: columnsController,
exportController: exportController,
scriptController: scriptController,
commandController: commandController,
msgSender: msgSender,
}

View file

@ -151,3 +151,37 @@ func (rs *ResultSet) Sort(criteria SortCriteria) {
rs.sortCriteria = criteria
Sort(rs.items, criteria)
}
func (rs *ResultSet) MergeWith(otherRS *ResultSet) *ResultSet {
type pksk struct {
pk types.AttributeValue
sk types.AttributeValue
}
if !rs.TableInfo.Equal(otherRS.TableInfo) {
return nil
}
itemsInI := make(map[pksk]Item)
newItems := make([]Item, 0, len(rs.Items())+len(otherRS.Items()))
for _, item := range rs.Items() {
pk, sk := item.PKSK(rs.TableInfo)
itemsInI[pksk{pk, sk}] = item
newItems = append(newItems, item)
}
for _, item := range otherRS.Items() {
pk, sk := item.PKSK(rs.TableInfo)
if _, hasItem := itemsInI[pksk{pk, sk}]; !hasItem {
newItems = append(newItems, item)
}
}
newResultSet := &ResultSet{
Created: time.Now(),
TableInfo: rs.TableInfo,
}
newResultSet.SetItems(newItems)
return newResultSet
}

View file

@ -8,6 +8,7 @@ import (
"github.com/pkg/errors"
"math/big"
"strconv"
"strings"
)
type exprValue interface {
@ -62,6 +63,14 @@ func newExprValueFromAttributeValue(ev types.AttributeValue) (exprValue, error)
case *types.AttributeValueMemberS:
return stringExprValue(xVal.Value), nil
case *types.AttributeValueMemberN:
if !strings.Contains(xVal.Value, ".") {
iVal, err := strconv.ParseInt(xVal.Value, 10, 64)
if err != nil {
return nil, err
}
return int64ExprValue(iVal), nil
}
xNumVal, _, err := big.ParseFloat(xVal.Value, 10, 63, big.ToNearestEven)
if err != nil {
return nil, err
@ -139,6 +148,32 @@ func (s int64ExprValue) typeName() string {
return "N"
}
type bigIntExprValue struct {
num *big.Int
}
func (i bigIntExprValue) asGoValue() any {
return i.num
}
func (i bigIntExprValue) asAttributeValue() types.AttributeValue {
return &types.AttributeValueMemberN{Value: i.num.String()}
}
func (i bigIntExprValue) asInt() int64 {
return i.num.Int64()
}
func (i bigIntExprValue) asBigFloat() *big.Float {
var f big.Float
f.SetInt64(i.num.Int64())
return &f
}
func (s bigIntExprValue) typeName() string {
return "N"
}
type bigNumExprValue struct {
num *big.Float
}

View file

@ -1,102 +0,0 @@
/**
* Builtins adopted and modified from Taramin
* Copyright (c) 2022 Curtis Myzie
*/
package scriptmanager
import (
"context"
"fmt"
"log"
"github.com/pkg/errors"
"github.com/risor-io/risor/object"
)
func printBuiltin(ctx context.Context, args ...object.Object) object.Object {
env := scriptEnvFromCtx(ctx)
prefix := "script " + env.filename + ":"
values := make([]interface{}, len(args)+1)
values[0] = prefix
for i, arg := range args {
switch arg := arg.(type) {
case *object.String:
values[i+1] = arg.Value()
default:
values[i+1] = arg.Inspect()
}
}
log.Println(values...)
return object.Nil
}
func printfBuiltin(ctx context.Context, args ...object.Object) object.Object {
env := scriptEnvFromCtx(ctx)
prefix := "script " + env.filename + ":"
numArgs := len(args)
if numArgs < 1 {
return object.Errorf("type error: printf() takes 1 or more arguments (%d given)", len(args))
}
format, err := object.AsString(args[0])
if err != nil {
return err
}
var values = []interface{}{prefix}
for _, arg := range args[1:] {
switch arg := arg.(type) {
case *object.String:
values = append(values, arg.Value())
default:
values = append(values, arg.Interface())
}
}
log.Printf("%s "+format, values...)
return object.Nil
}
// This is taken from the args package
func require(funcName string, count int, args []object.Object) *object.Error {
nArgs := len(args)
if nArgs != count {
if count == 1 {
return object.Errorf(
fmt.Sprintf("type error: %s() takes exactly 1 argument (%d given)",
funcName, nArgs))
}
return object.Errorf(
fmt.Sprintf("type error: %s() takes exactly %d arguments (%d given)",
funcName, count, nArgs))
}
return nil
}
func bindArgs(funcName string, args []object.Object, bindArgs ...any) *object.Error {
if err := require(funcName, len(bindArgs), args); err != nil {
return err
}
for i, bindArg := range bindArgs {
switch t := bindArg.(type) {
case *string:
str, err := object.AsString(args[i])
if err != nil {
return err
}
*t = str
case **object.Function:
fnRes, isFnRes := args[i].(*object.Function)
if !isFnRes {
return object.NewError(errors.Errorf("expected arg %v to be a function, was %T", i, bindArg))
}
*t = fnRes
default:
return object.NewError(errors.Errorf("unhandled arg type %v", i))
}
}
return nil
}

View file

@ -1,38 +0,0 @@
package scriptmanager
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
)
//go:generate mockery --with-expecter --name UIService
//go:generate mockery --with-expecter --name SessionService
type Ifaces struct {
UI UIService
Session SessionService
}
type UIService interface {
PrintMessage(ctx context.Context, msg string)
// Prompt should return a channel which will provide the input from the user. If the user
// provides no input, prompt should close the channel without providing anything.
Prompt(ctx context.Context, msg string) chan string
}
type SessionService interface {
Query(ctx context.Context, expr string, queryOptions QueryOptions) (*models.ResultSet, error)
ResultSet(ctx context.Context) *models.ResultSet
SelectedItemIndex(ctx context.Context) int
SetResultSet(ctx context.Context, newResultSet *models.ResultSet)
}
type QueryOptions struct {
TableName string
IndexName string
NamePlaceholders map[string]string
ValuePlaceholders map[string]types.AttributeValue
}

View file

@ -1,216 +0,0 @@
// Code generated by mockery v2.20.0. DO NOT EDIT.
package mocks
import (
context "context"
models "github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
mock "github.com/stretchr/testify/mock"
scriptmanager "github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
)
// SessionService is an autogenerated mock type for the SessionService type
type SessionService struct {
mock.Mock
}
type SessionService_Expecter struct {
mock *mock.Mock
}
func (_m *SessionService) EXPECT() *SessionService_Expecter {
return &SessionService_Expecter{mock: &_m.Mock}
}
// Query provides a mock function with given fields: ctx, expr, queryOptions
func (_m *SessionService) Query(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions) (*models.ResultSet, error) {
ret := _m.Called(ctx, expr, queryOptions)
var r0 *models.ResultSet
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)); ok {
return rf(ctx, expr, queryOptions)
}
if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) *models.ResultSet); ok {
r0 = rf(ctx, expr, queryOptions)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.ResultSet)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, scriptmanager.QueryOptions) error); ok {
r1 = rf(ctx, expr, queryOptions)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SessionService_Query_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Query'
type SessionService_Query_Call struct {
*mock.Call
}
// Query is a helper method to define mock.On call
// - ctx context.Context
// - expr string
// - queryOptions scriptmanager.QueryOptions
func (_e *SessionService_Expecter) Query(ctx interface{}, expr interface{}, queryOptions interface{}) *SessionService_Query_Call {
return &SessionService_Query_Call{Call: _e.mock.On("Query", ctx, expr, queryOptions)}
}
func (_c *SessionService_Query_Call) Run(run func(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions)) *SessionService_Query_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(scriptmanager.QueryOptions))
})
return _c
}
func (_c *SessionService_Query_Call) Return(_a0 *models.ResultSet, _a1 error) *SessionService_Query_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *SessionService_Query_Call) RunAndReturn(run func(context.Context, string, scriptmanager.QueryOptions) (*models.ResultSet, error)) *SessionService_Query_Call {
_c.Call.Return(run)
return _c
}
// ResultSet provides a mock function with given fields: ctx
func (_m *SessionService) ResultSet(ctx context.Context) *models.ResultSet {
ret := _m.Called(ctx)
var r0 *models.ResultSet
if rf, ok := ret.Get(0).(func(context.Context) *models.ResultSet); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.ResultSet)
}
}
return r0
}
// SessionService_ResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResultSet'
type SessionService_ResultSet_Call struct {
*mock.Call
}
// ResultSet is a helper method to define mock.On call
// - ctx context.Context
func (_e *SessionService_Expecter) ResultSet(ctx interface{}) *SessionService_ResultSet_Call {
return &SessionService_ResultSet_Call{Call: _e.mock.On("ResultSet", ctx)}
}
func (_c *SessionService_ResultSet_Call) Run(run func(ctx context.Context)) *SessionService_ResultSet_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *SessionService_ResultSet_Call) Return(_a0 *models.ResultSet) *SessionService_ResultSet_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *SessionService_ResultSet_Call) RunAndReturn(run func(context.Context) *models.ResultSet) *SessionService_ResultSet_Call {
_c.Call.Return(run)
return _c
}
// SelectedItemIndex provides a mock function with given fields: ctx
func (_m *SessionService) SelectedItemIndex(ctx context.Context) int {
ret := _m.Called(ctx)
var r0 int
if rf, ok := ret.Get(0).(func(context.Context) int); ok {
r0 = rf(ctx)
} else {
r0 = ret.Get(0).(int)
}
return r0
}
// SessionService_SelectedItemIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectedItemIndex'
type SessionService_SelectedItemIndex_Call struct {
*mock.Call
}
// SelectedItemIndex is a helper method to define mock.On call
// - ctx context.Context
func (_e *SessionService_Expecter) SelectedItemIndex(ctx interface{}) *SessionService_SelectedItemIndex_Call {
return &SessionService_SelectedItemIndex_Call{Call: _e.mock.On("SelectedItemIndex", ctx)}
}
func (_c *SessionService_SelectedItemIndex_Call) Run(run func(ctx context.Context)) *SessionService_SelectedItemIndex_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *SessionService_SelectedItemIndex_Call) Return(_a0 int) *SessionService_SelectedItemIndex_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *SessionService_SelectedItemIndex_Call) RunAndReturn(run func(context.Context) int) *SessionService_SelectedItemIndex_Call {
_c.Call.Return(run)
return _c
}
// SetResultSet provides a mock function with given fields: ctx, newResultSet
func (_m *SessionService) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) {
_m.Called(ctx, newResultSet)
}
// SessionService_SetResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetResultSet'
type SessionService_SetResultSet_Call struct {
*mock.Call
}
// SetResultSet is a helper method to define mock.On call
// - ctx context.Context
// - newResultSet *models.ResultSet
func (_e *SessionService_Expecter) SetResultSet(ctx interface{}, newResultSet interface{}) *SessionService_SetResultSet_Call {
return &SessionService_SetResultSet_Call{Call: _e.mock.On("SetResultSet", ctx, newResultSet)}
}
func (_c *SessionService_SetResultSet_Call) Run(run func(ctx context.Context, newResultSet *models.ResultSet)) *SessionService_SetResultSet_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*models.ResultSet))
})
return _c
}
func (_c *SessionService_SetResultSet_Call) Return() *SessionService_SetResultSet_Call {
_c.Call.Return()
return _c
}
func (_c *SessionService_SetResultSet_Call) RunAndReturn(run func(context.Context, *models.ResultSet)) *SessionService_SetResultSet_Call {
_c.Call.Return(run)
return _c
}
type mockConstructorTestingTNewSessionService interface {
mock.TestingT
Cleanup(func())
}
// NewSessionService creates a new instance of SessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewSessionService(t mockConstructorTestingTNewSessionService) *SessionService {
mock := &SessionService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -1,116 +0,0 @@
// Code generated by mockery v2.20.0. DO NOT EDIT.
package mocks
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// UIService is an autogenerated mock type for the UIService type
type UIService struct {
mock.Mock
}
type UIService_Expecter struct {
mock *mock.Mock
}
func (_m *UIService) EXPECT() *UIService_Expecter {
return &UIService_Expecter{mock: &_m.Mock}
}
// PrintMessage provides a mock function with given fields: ctx, msg
func (_m *UIService) PrintMessage(ctx context.Context, msg string) {
_m.Called(ctx, msg)
}
// UIService_PrintMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrintMessage'
type UIService_PrintMessage_Call struct {
*mock.Call
}
// PrintMessage is a helper method to define mock.On call
// - ctx context.Context
// - msg string
func (_e *UIService_Expecter) PrintMessage(ctx interface{}, msg interface{}) *UIService_PrintMessage_Call {
return &UIService_PrintMessage_Call{Call: _e.mock.On("PrintMessage", ctx, msg)}
}
func (_c *UIService_PrintMessage_Call) Run(run func(ctx context.Context, msg string)) *UIService_PrintMessage_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *UIService_PrintMessage_Call) Return() *UIService_PrintMessage_Call {
_c.Call.Return()
return _c
}
func (_c *UIService_PrintMessage_Call) RunAndReturn(run func(context.Context, string)) *UIService_PrintMessage_Call {
_c.Call.Return(run)
return _c
}
// Prompt provides a mock function with given fields: ctx, msg
func (_m *UIService) Prompt(ctx context.Context, msg string) chan string {
ret := _m.Called(ctx, msg)
var r0 chan string
if rf, ok := ret.Get(0).(func(context.Context, string) chan string); ok {
r0 = rf(ctx, msg)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(chan string)
}
}
return r0
}
// UIService_Prompt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Prompt'
type UIService_Prompt_Call struct {
*mock.Call
}
// Prompt is a helper method to define mock.On call
// - ctx context.Context
// - msg string
func (_e *UIService_Expecter) Prompt(ctx interface{}, msg interface{}) *UIService_Prompt_Call {
return &UIService_Prompt_Call{Call: _e.mock.On("Prompt", ctx, msg)}
}
func (_c *UIService_Prompt_Call) Run(run func(ctx context.Context, msg string)) *UIService_Prompt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *UIService_Prompt_Call) Return(_a0 chan string) *UIService_Prompt_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *UIService_Prompt_Call) RunAndReturn(run func(context.Context, string) chan string) *UIService_Prompt_Call {
_c.Call.Return(run)
return _c
}
type mockConstructorTestingTNewUIService interface {
mock.TestingT
Cleanup(func())
}
// NewUIService creates a new instance of UIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewUIService(t mockConstructorTestingTNewUIService) *UIService {
mock := &UIService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -1,270 +0,0 @@
package scriptmanager
import (
"context"
"fmt"
"regexp"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/pkg/errors"
"github.com/risor-io/risor/object"
)
var (
validKeyBindingNames = regexp.MustCompile(`^[-a-zA-Z0-9_]+$`)
)
type extModule struct {
scriptPlugin *ScriptPlugin
}
func (m *extModule) register() *object.Module {
return object.NewBuiltinsModule("ext", map[string]object.Object{
"command": object.NewBuiltin("command", m.command),
"key_binding": object.NewBuiltin("key_binding", m.keyBinding),
"related_items": object.NewBuiltin("related_items", m.relatedItem),
})
}
func (m *extModule) command(ctx context.Context, args ...object.Object) object.Object {
thisEnv := scriptEnvFromCtx(ctx)
if err := require("ext.command", 2, args); err != nil {
return err
}
cmdName, err := object.AsString(args[0])
if err != nil {
return err
}
fnRes, isFnRes := args[1].(*object.Function)
if !isFnRes {
return object.NewError(errors.New("expected second arg to be a function"))
}
callFn, hasCallFn := object.GetCallFunc(ctx)
if !hasCallFn {
return object.NewError(errors.New("no callFn found in context"))
}
// This command function will be executed by the script scheduler
newCommand := func(ctx context.Context, args []string) error {
objArgs := make([]object.Object, len(args))
for i, a := range args {
objArgs[i] = object.NewString(a)
}
newEnv := thisEnv
ctx = ctxWithScriptEnv(ctx, newEnv)
res, err := callFn(ctx, fnRes, objArgs)
if err != nil {
return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, cmdName, err)
} else if object.IsError(res) {
errObj := res.(*object.Error)
return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, cmdName, errObj.Inspect())
}
return nil
}
if m.scriptPlugin.definedCommands == nil {
m.scriptPlugin.definedCommands = make(map[string]*Command)
}
m.scriptPlugin.definedCommands[cmdName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand}
return nil
}
func (m *extModule) keyBinding(ctx context.Context, args ...object.Object) object.Object {
thisEnv := scriptEnvFromCtx(ctx)
if err := require("ext.key_binding", 3, args); err != nil {
return err
}
bindingName, err := object.AsString(args[0])
if err != nil {
return err
} else if !validKeyBindingNames.MatchString(bindingName) {
return object.NewError(errors.New("value error: binding name must match regexp [-a-zA-Z0-9_]+"))
}
options, err := object.AsMap(args[1])
if err != nil {
return err
}
var defaultKey string
if strVal, isStrVal := options.Get("default").(*object.String); isStrVal {
defaultKey = strVal.Value()
}
fnRes, isFnRes := args[2].(*object.Function)
if !isFnRes {
return object.NewError(errors.New("expected second arg to be a function"))
}
callFn, hasCallFn := object.GetCallFunc(ctx)
if !hasCallFn {
return object.NewError(errors.New("no callFn found in context"))
}
// This command function will be executed by the script scheduler
newCommand := func(ctx context.Context, args []string) error {
objArgs := make([]object.Object, len(args))
for i, a := range args {
objArgs[i] = object.NewString(a)
}
newEnv := thisEnv
ctx = ctxWithScriptEnv(ctx, newEnv)
res, err := callFn(ctx, fnRes, objArgs)
if err != nil {
return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, bindingName, err)
} else if object.IsError(res) {
errObj := res.(*object.Error)
return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, bindingName, errObj.Inspect())
}
return nil
}
fullBindingName := fmt.Sprintf("ext.%v.%v", m.scriptPlugin.name, bindingName)
if m.scriptPlugin.definedKeyBindings == nil {
m.scriptPlugin.definedKeyBindings = make(map[string]*Command)
m.scriptPlugin.keyToKeyBinding = make(map[string]string)
}
m.scriptPlugin.definedKeyBindings[fullBindingName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand}
m.scriptPlugin.keyToKeyBinding[defaultKey] = fullBindingName
return nil
}
func (m *extModule) relatedItem(ctx context.Context, args ...object.Object) object.Object {
thisEnv := scriptEnvFromCtx(ctx)
var (
tableName string
callbackFn *object.Function
)
if err := bindArgs("ext.related_items", args, &tableName, &callbackFn); err != nil {
return err
}
callFn, hasCallFn := object.GetCallFunc(ctx)
if !hasCallFn {
return object.NewError(errors.New("no callFn found in context"))
}
newHandler := func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error) {
newEnv := thisEnv
ctx = ctxWithScriptEnv(ctx, newEnv)
res, err := callFn(ctx, callbackFn, []object.Object{
newItemProxy(newResultSetProxy(rs), index),
})
if err != nil {
return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, err)
} else if object.IsError(res) {
errObj := res.(*object.Error)
return nil, errors.Errorf("script error '%v':related_item - %v", m.scriptPlugin.name, errObj.Inspect())
}
itr, objErr := object.AsIterator(res)
if err != nil {
return nil, objErr.Value()
}
var relItems []relatedItem
for next, hasNext := itr.Next(ctx); hasNext; next, hasNext = itr.Next(ctx) {
var newRelItem relatedItem
itemMap, objErr := object.AsMap(next)
if err != nil {
return nil, objErr.Value()
}
labelName, objErr := object.AsString(itemMap.Get("label"))
if objErr != nil {
continue
}
newRelItem.label = labelName
var tableStr = ""
if itemMap.Get("table") != object.Nil {
tableStr, objErr = object.AsString(itemMap.Get("table"))
if objErr != nil {
continue
}
}
newRelItem.table = tableStr
if selectFn, ok := itemMap.Get("on_select").(*object.Function); ok {
newRelItem.onSelect = func() error {
thisNewEnv := thisEnv
ctx = ctxWithScriptEnv(ctx, thisNewEnv)
res, err := callFn(ctx, selectFn, []object.Object{})
if err != nil {
return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, err)
} else if object.IsError(res) {
errObj := res.(*object.Error)
return errors.Errorf("rel error '%v' - %v", m.scriptPlugin.name, errObj.Inspect())
}
return nil
}
} else {
queryExprStr, objErr := object.AsString(itemMap.Get("query"))
if objErr != nil {
continue
}
query, err := queryexpr.Parse(queryExprStr)
if err != nil {
continue
}
// Placeholders
if argsVal, isArgsValMap := object.AsMap(itemMap.Get("args")); isArgsValMap == nil {
namePlaceholders := make(map[string]string)
valuePlaceholders := make(map[string]types.AttributeValue)
for k, val := range argsVal.Value() {
switch v := val.(type) {
case *object.String:
namePlaceholders[k] = v.Value()
valuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()}
case *object.Int:
valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
case *object.Float:
valuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
case *object.Bool:
valuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()}
case *object.NilType:
valuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true}
default:
continue
}
}
query = query.WithNameParams(namePlaceholders).WithValueParams(valuePlaceholders)
}
newRelItem.query = query
}
relItems = append(relItems, newRelItem)
}
return relItems, nil
}
m.scriptPlugin.relatedItems = append(m.scriptPlugin.relatedItems, &relatedItemBuilder{
table: tableName,
itemProduction: newHandler,
})
return nil
}

View file

@ -1,151 +0,0 @@
package scriptmanager_test
import (
"context"
"testing"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/stretchr/testify/assert"
)
func TestExtModule_RelatedItems(t *testing.T) {
t.Run("should register a function which will return related items for an item", func(t *testing.T) {
scenarios := []struct {
desc string
code string
}{
{
desc: "single function, table name match",
code: `
ext.related_items("test-table", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
{
desc: "single function, table prefix match",
code: `
ext.related_items("test-*", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
{
desc: "multi function, table name match",
code: `
ext.related_items("test-table", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
]
})
ext.related_items("test-table", func(item) {
return [
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
{
desc: "multi function, table name prefix",
code: `
ext.related_items("test-*", func(item) {
print("Hello")
return [
{"label": "Customer", "query": "pk=$foo", "args": {"foo": "foo"}},
]
})
ext.related_items("test-*", func(item) {
return [
{"label": "Payment", "query": "fla=$daa", "args": {"daa": "Hello"}},
]
})
`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.desc, func(t *testing.T) {
// Load the script
srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", scenario.code)))
ctx := context.Background()
plugin, err := srv.LoadScript(ctx, "test.tm")
assert.NoError(t, err)
assert.NotNil(t, plugin)
// Get related items of result set
rs := &models.ResultSet{
TableInfo: &models.TableInfo{
Name: "test-table",
},
}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0)
assert.NoError(t, err)
assert.Len(t, relItems, 2)
assert.Equal(t, "Customer", relItems[0].Name)
assert.Equal(t, "pk=$foo", relItems[0].Query.String())
assert.Equal(t, "foo", relItems[0].Query.ValueParamOrNil("foo").(*types.AttributeValueMemberS).Value)
assert.Equal(t, "Payment", relItems[1].Name)
assert.Equal(t, "fla=$daa", relItems[1].Query.String())
assert.Equal(t, "Hello", relItems[1].Query.ValueParamOrNil("daa").(*types.AttributeValueMemberS).Value)
})
}
})
t.Run("should support rel_items with on select", func(t *testing.T) {
// Load the script
srv := scriptmanager.New(scriptmanager.WithFS(testScriptFile(t, "test.tm", `
ext.related_items("test-table", func(item) {
print("Hello")
return [
{"label": "Customer", "on_select": func() {
print("Selected")
}},
]
})
`)))
ctx := context.Background()
plugin, err := srv.LoadScript(ctx, "test.tm")
assert.NoError(t, err)
assert.NotNil(t, plugin)
// Get related items of result set
rs := &models.ResultSet{
TableInfo: &models.TableInfo{
Name: "test-table",
},
}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
relItems, err := srv.RelatedItemOfItem(context.Background(), rs, 0)
assert.NoError(t, err)
assert.Len(t, relItems, 1)
assert.Equal(t, "Customer", relItems[0].Name)
assert.NoError(t, relItems[0].OnSelect())
})
}

View file

@ -1,56 +0,0 @@
package scriptmanager_test
import (
"context"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
func TestOSModule_Env(t *testing.T) {
t.Run("should return value of environment variables", func(t *testing.T) {
t.Setenv("FULL_VALUE", "this is a value")
t.Setenv("EMPTY_VALUE", "")
testFS := testScriptFile(t, "test.tm", `
assert(os.getenv("FULL_VALUE") == "this is a value")
assert(os.getenv("EMPTY_VALUE") == "")
assert(os.getenv("MISSING_VALUE") == "")
assert(bool(os.getenv("FULL_VALUE")) == true)
assert(bool(os.getenv("EMPTY_VALUE")) == false)
assert(bool(os.getenv("MISSING_VALUE")) == false)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
})
}
func TestOSModule_Exec(t *testing.T) {
t.Run("should run command and return stdout", func(t *testing.T) {
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "hello world\n")
testFS := testScriptFile(t, "test.tm", `
res := exec('echo', ["hello world"]).stdout
ui.print(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
})
}

View file

@ -1,145 +0,0 @@
package scriptmanager
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/pkg/errors"
"github.com/risor-io/risor/object"
)
type sessionModule struct {
sessionService SessionService
}
func (um *sessionModule) query(ctx context.Context, args ...object.Object) object.Object {
if len(args) == 0 || len(args) > 2 {
return object.Errorf("type error: session.query takes either 1 or 2 arguments (%d given)", len(args))
}
var options QueryOptions
expr, objErr := object.AsString(args[0])
if objErr != nil {
return objErr
}
if len(args) == 2 {
objMap, objErr := object.AsMap(args[1])
if objErr != nil {
return objErr
}
// Table name
if val := objMap.Get("table"); val != object.Nil && val.IsTruthy() {
switch tv := val.(type) {
case *object.String:
options.TableName = tv.Value()
case *tableProxy:
options.TableName = tv.table.Name
default:
return object.Errorf("type error: query option 'table' must be either a string or table")
}
}
// Index name
if val, isStr := objMap.Get("index").(*object.String); isStr {
options.IndexName = val.Value()
}
// Placeholders
if argsVal, isArgsValMap := objMap.Get("args").(*object.Map); isArgsValMap {
options.NamePlaceholders = make(map[string]string)
options.ValuePlaceholders = make(map[string]types.AttributeValue)
for k, val := range argsVal.Value() {
switch v := val.(type) {
case *object.String:
options.NamePlaceholders[k] = v.Value()
options.ValuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()}
case *object.Int:
options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
case *object.Float:
options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
case *object.Bool:
options.ValuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()}
case *object.NilType:
options.ValuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true}
default:
return object.Errorf("type error: arg '%v' of type '%v' is not supported", k, val.Type())
}
}
}
}
resp, err := um.sessionService.Query(ctx, expr, options)
if err != nil {
return object.NewError(err)
}
return &resultSetProxy{resultSet: resp}
}
func (um *sessionModule) resultSet(ctx context.Context, args ...object.Object) object.Object {
if err := require("session.result_set", 0, args); err != nil {
return err
}
rs := um.sessionService.ResultSet(ctx)
if rs == nil {
return object.Nil
}
return &resultSetProxy{resultSet: rs}
}
func (um *sessionModule) selectedItem(ctx context.Context, args ...object.Object) object.Object {
if err := require("session.result_set", 0, args); err != nil {
return err
}
rs := um.sessionService.ResultSet(ctx)
idx := um.sessionService.SelectedItemIndex(ctx)
if rs == nil || idx < 0 {
return object.Nil
}
rsProxy := &resultSetProxy{resultSet: rs}
return newItemProxy(rsProxy, idx)
}
func (um *sessionModule) setResultSet(ctx context.Context, args ...object.Object) object.Object {
if err := require("session.set_result_set", 1, args); err != nil {
return err
}
resultSetProxy, isResultSetProxy := args[0].(*resultSetProxy)
if !isResultSetProxy {
return object.NewError(errors.Errorf("type error: expected a resultsset (got %v)", args[0]))
}
um.sessionService.SetResultSet(ctx, resultSetProxy.resultSet)
return nil
}
func (um *sessionModule) currentTable(ctx context.Context, args ...object.Object) object.Object {
if err := require("session.current_table", 0, args); err != nil {
return err
}
rs := um.sessionService.ResultSet(ctx)
if rs == nil {
return object.Nil
}
return &tableProxy{table: rs.TableInfo}
}
func (um *sessionModule) register() *object.Module {
return object.NewBuiltinsModule("session", map[string]object.Object{
"query": object.NewBuiltin("query", um.query),
"current_table": object.NewBuiltin("current_table", um.currentTable),
"result_set": object.NewBuiltin("result_set", um.resultSet),
"selected_item": object.NewBuiltin("selected_item", um.selectedItem),
"set_result_set": object.NewBuiltin("set_result_set", um.setResultSet),
})
}

View file

@ -1,426 +0,0 @@
package scriptmanager_test
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
func TestModSession_Table(t *testing.T) {
t.Run("should return details of the current table", func(t *testing.T) {
tableDef := models.TableInfo{
Name: "test_table",
Keys: models.KeyAttribute{
PartitionKey: "pk",
SortKey: "sk",
},
GSIs: []models.TableGSI{
{
Name: "index-1",
Keys: models.KeyAttribute{
PartitionKey: "ipk",
SortKey: "isk",
},
},
},
}
rs := models.ResultSet{TableInfo: &tableDef}
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&rs)
testFS := testScriptFile(t, "test.tm", `
table := session.current_table()
assert(table.name == "test_table")
assert(table.keys["hash"] == "pk")
assert(table.keys["range"] == "sk")
assert(len(table.gsis) == 1)
assert(table.gsis[0].name == "index-1")
assert(table.gsis[0].keys["hash"] == "ipk")
assert(table.gsis[0].keys["range"] == "isk")
assert(table == session.result_set().table)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
t.Run("should return nil if no current result set", func(t *testing.T) {
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(nil)
testFS := testScriptFile(t, "test.tm", `
table := session.current_table()
assert(table == nil)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
}
func TestModSession_Query(t *testing.T) {
t.Run("should successfully return query result", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "2")
mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[0]['pk'].S = abc")
mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1]['pk'].S = 1232")
mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1].attr('size(pk)') = 4")
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
ui.print(res.length)
ui.print("res[0]['pk'].S = ", res[0].attr("pk"))
ui.print("res[1]['pk'].S = ", res[1].attr("pk"))
ui.print("res[1].attr('size(pk)') = ", res[1].attr("size(pk)"))
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
t.Run("should return error if query returns error", func(t *testing.T) {
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(nil, errors.New("bang"))
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.Error(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
t.Run("should successfully specify table name", func(t *testing.T) {
rs := &models.ResultSet{}
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{
TableName: "some-table",
}).Return(rs, nil)
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr", {
table: "some-table",
})
assert(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
t.Run("should successfully specify table proxy", func(t *testing.T) {
rs := &models.ResultSet{}
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(&models.ResultSet{
TableInfo: &models.TableInfo{
Name: "some-resultset-table",
},
})
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{
TableName: "some-resultset-table",
}).Return(rs, nil)
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr", {
table: session.result_set().table,
})
assert(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
t.Run("should set placeholder values", func(t *testing.T) {
rs := &models.ResultSet{}
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{
NamePlaceholders: map[string]string{
"name": "hello",
"value": "world",
},
ValuePlaceholders: map[string]types.AttributeValue{
"name": &types.AttributeValueMemberS{Value: "hello"},
"value": &types.AttributeValueMemberS{Value: "world"},
},
}).Return(rs, nil)
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query(":name = $value", {
args: {
name: "hello",
value: "world",
},
})
assert(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
t.Run("should support various placeholder value type", func(t *testing.T) {
rs := &models.ResultSet{}
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{
NamePlaceholders: map[string]string{
"str": "hello",
},
ValuePlaceholders: map[string]types.AttributeValue{
"str": &types.AttributeValueMemberS{Value: "hello"},
"int": &types.AttributeValueMemberN{Value: "123"},
"float": &types.AttributeValueMemberN{Value: "3.14"},
"bool": &types.AttributeValueMemberBOOL{Value: true},
"nil": &types.AttributeValueMemberNULL{Value: true},
},
}).Return(rs, nil)
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query(":name = $value", {
args: {
"str": "hello",
"int": 123,
"float": 3.14,
"bool": true,
"nil": nil,
},
})
assert(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
t.Run("should return error when placeholder value type is unsupported", func(t *testing.T) {
mockedSessionService := mocks.NewSessionService(t)
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query(":name = $value", {
args: {
"bad": func() { },
},
})
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.Error(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
}
func TestModSession_SelectedItem(t *testing.T) {
t.Run("should return selected item from service implementation", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs)
mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(1)
testFS := testScriptFile(t, "test.tm", `
selItem := session.selected_item()
assert(selItem != nil, "selItem != nil")
assert(selItem.index == 1, "selItem.index")
assert(selItem.result_set == session.result_set(), "selItem.result_set")
assert(selItem.attr('pk') == '1232', "selItem.attr('pk')")
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
t.Run("should return nil if selected item returns -1", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs)
mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(-1)
testFS := testScriptFile(t, "test.tm", `
selItem := session.selected_item()
assert(selItem == nil, "selItem != nil")
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
}
func TestModSession_SetResultSet(t *testing.T) {
t.Run("should set the result set on the session", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
mockedSessionService.EXPECT().SetResultSet(mock.Anything, rs)
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
session.set_result_set(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
}

View file

@ -1,58 +0,0 @@
package scriptmanager
import (
"context"
"strings"
"github.com/risor-io/risor/object"
)
type uiModule struct {
uiService UIService
}
func (um *uiModule) print(ctx context.Context, args ...object.Object) object.Object {
var msg strings.Builder
for _, arg := range args {
if arg == nil {
continue
}
switch a := arg.(type) {
case *object.String:
msg.WriteString(a.Value())
default:
msg.WriteString(a.Inspect())
}
}
um.uiService.PrintMessage(ctx, msg.String())
return object.Nil
}
func (um *uiModule) prompt(ctx context.Context, args ...object.Object) object.Object {
if err := require("ui.prompt", 1, args); err != nil {
return err
}
msg, _ := object.AsString(args[0])
respChan := um.uiService.Prompt(ctx, msg)
select {
case resp, hasResp := <-respChan:
if hasResp {
return object.NewString(resp)
} else {
return object.Nil
}
case <-ctx.Done():
return object.NewError(ctx.Err())
}
}
func (um *uiModule) register() *object.Module {
return object.NewBuiltinsModule("ui", map[string]object.Object{
"print": object.NewBuiltin("print", um.print),
"prompt": object.NewBuiltin("prompt", um.prompt),
})
}

View file

@ -1,100 +0,0 @@
package scriptmanager_test
import (
"context"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
func TestModUI_Prompt(t *testing.T) {
t.Run("should successfully return prompt value", func(t *testing.T) {
testFS := testScriptFile(t, "test.tm", `
ui.print("Hello, world")
var name = ui.prompt("What is your name? ")
ui.print("Hello, " + name)
`)
promptChan := make(chan string)
go func() {
promptChan <- "T. Test"
}()
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, T. Test")
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
})
t.Run("should return nil if prompt was cancelled", func(t *testing.T) {
testFS := testScriptFile(t, "test.tm", `
ui.print("Hello, world")
var name = ui.prompt("What is your name? ")
ui.print("After")
ui.print(nil)
`)
promptChan := make(chan string)
close(promptChan)
ctx := context.Background()
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
mockedUIService.EXPECT().PrintMessage(mock.Anything, "After")
mockedUIService.EXPECT().PrintMessage(mock.Anything, "nil")
mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
})
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
})
t.Run("should return error if context was cancelled", func(t *testing.T) {
testFS := testScriptFile(t, "test.tm", `
ui.print("Hello, world")
var name = ui.prompt("What is your name? ")
ui.print("After")
`)
promptChan := make(chan string)
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Run(func(ctx context.Context, msg string) {
cancelFn()
}).Return(promptChan)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
})
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.Error(t, err)
mockedUIService.AssertNotCalled(t, "Prompt", "after")
mockedUIService.AssertExpectations(t)
})
}

View file

@ -1,26 +0,0 @@
package scriptmanager
import (
"context"
"github.com/risor-io/risor/limits"
)
// scriptEnv is the runtime environment for a particular script execution
type scriptEnv struct {
filename string
}
type scriptEnvKeyType struct{}
var scriptEnvKey = scriptEnvKeyType{}
func scriptEnvFromCtx(ctx context.Context) scriptEnv {
perms, _ := ctx.Value(scriptEnvKey).(scriptEnv)
return perms
}
func ctxWithScriptEnv(ctx context.Context, perms scriptEnv) context.Context {
newCtx := context.WithValue(ctx, scriptEnvKey, perms)
newCtx = limits.WithLimits(newCtx, limits.New())
return newCtx
}

View file

@ -1,57 +0,0 @@
package scriptmanager
import (
"context"
"log"
"path"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/relitems"
)
type relatedItem struct {
label string
table string
query *queryexpr.QueryExpr
onSelect func() error
}
type relatedItemBuilder struct {
table string
itemProduction func(ctx context.Context, rs *models.ResultSet, index int) ([]relatedItem, error)
}
func (s *Service) RelatedItemOfItem(ctx context.Context, rs *models.ResultSet, index int) ([]relitems.RelatedItem, error) {
riModels := []relitems.RelatedItem{}
for _, plugin := range s.plugins {
for _, rb := range plugin.relatedItems {
// TODO: should support matching
match, _ := tableMatchesGlob(rb.table, rs.TableInfo.Name)
log.Printf("RelatedItemOfItem: table = '%v', pattern = '%v', match = '%v'", rb.table, rs.TableInfo.Name, match)
if match {
relatedItems, err := rb.itemProduction(ctx, rs, index)
if err != nil {
// TODO: should probably return error if no rel items were found and an error was raised
return nil, err
}
// TODO: make this nicer
for _, ri := range relatedItems {
riModels = append(riModels, relitems.RelatedItem{
Name: ri.label,
Query: ri.query,
Table: ri.table,
OnSelect: ri.onSelect,
})
}
}
}
}
return riModels, nil
}
func tableMatchesGlob(tableName, pattern string) (bool, error) {
return path.Match(tableName, pattern)
}

View file

@ -1,337 +0,0 @@
package scriptmanager
import (
"context"
"time"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/attrutils"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
"github.com/pkg/errors"
"github.com/risor-io/risor/object"
"github.com/risor-io/risor/op"
)
type resultSetProxy struct {
resultSet *models.ResultSet
}
func newResultSetProxy(rs *models.ResultSet) *resultSetProxy {
return &resultSetProxy{resultSet: rs}
}
func (r *resultSetProxy) SetAttr(name string, value object.Object) error {
return errors.Errorf("attribute error: %v", name)
}
func (r *resultSetProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object {
return object.Errorf("op error: unsupported %v", opType)
}
func (r *resultSetProxy) Cost() int {
return len(r.resultSet.Items())
}
func (r *resultSetProxy) Interface() interface{} {
return r.resultSet
}
func (r *resultSetProxy) IsTruthy() bool {
return true
}
func (r *resultSetProxy) Type() object.Type {
return "resultset"
}
func (r *resultSetProxy) Inspect() string {
return "resultset"
}
func (r *resultSetProxy) Equals(other object.Object) object.Object {
otherRS, isOtherRS := other.(*resultSetProxy)
if !isOtherRS {
return object.False
}
return object.NewBool(r.resultSet == otherRS.resultSet)
}
// GetItem implements the [key] operator for a container type.
func (r *resultSetProxy) GetItem(key object.Object) (object.Object, *object.Error) {
idx, err := object.AsInt(key)
if err != nil {
return nil, err
}
realIdx := int(idx)
if realIdx < 0 {
realIdx = len(r.resultSet.Items()) + realIdx
}
if realIdx < 0 || realIdx >= len(r.resultSet.Items()) {
return nil, object.NewError(errors.Errorf("index error: index out of range: %v", idx))
}
return newItemProxy(r, realIdx), nil
}
// GetSlice implements the [start:stop] operator for a container type.
func (r *resultSetProxy) GetSlice(s object.Slice) (object.Object, *object.Error) {
return nil, object.NewError(errors.New("TODO"))
}
// SetItem implements the [key] = value operator for a container type.
func (r *resultSetProxy) SetItem(key, value object.Object) *object.Error {
return object.NewError(errors.New("TODO"))
}
// DelItem implements the del [key] operator for a container type.
func (r *resultSetProxy) DelItem(key object.Object) *object.Error {
return object.NewError(errors.New("TODO"))
}
// Contains returns true if the given item is found in this container.
func (r *resultSetProxy) Contains(item object.Object) *object.Bool {
// TODO
return object.False
}
// Len returns the number of items in this container.
func (r *resultSetProxy) Len() *object.Int {
return object.NewInt(int64(len(r.resultSet.Items())))
}
// Iter returns an iterator for this container.
func (r *resultSetProxy) Iter() object.Iterator {
// TODO
return nil
}
func (r *resultSetProxy) GetAttr(name string) (object.Object, bool) {
switch name {
case "table":
return &tableProxy{table: r.resultSet.TableInfo}, true
case "length":
return object.NewInt(int64(len(r.resultSet.Items()))), true
case "find":
return object.NewBuiltin("find", r.find), true
case "merge":
return object.NewBuiltin("merge", r.merge), true
}
return nil, false
}
func (i *resultSetProxy) find(ctx context.Context, args ...object.Object) object.Object {
if objErr := require("resultset.find", 1, args); objErr != nil {
return objErr
}
str, objErr := object.AsString(args[0])
if objErr != nil {
return objErr
}
modExpr, err := queryexpr.Parse(str)
if err != nil {
return object.Errorf("arg error: invalid path expression: %v", err)
}
for idx, item := range i.resultSet.Items() {
rs, err := modExpr.EvalItem(item)
if err != nil {
continue
}
if attrutils.Truthy(rs) {
return newItemProxy(i, idx)
}
}
return object.Nil
}
func (i *resultSetProxy) merge(ctx context.Context, args ...object.Object) object.Object {
type pksk struct {
pk types.AttributeValue
sk types.AttributeValue
}
if objErr := require("resultset.merge", 1, args); objErr != nil {
return objErr
}
otherRS, isRS := args[0].(*resultSetProxy)
if !isRS {
return object.NewError(errors.Errorf("type error: expected a resultset (got %v)", args[0].Type()))
}
if !i.resultSet.TableInfo.Equal(otherRS.resultSet.TableInfo) {
return object.Nil
}
itemsInI := make(map[pksk]models.Item)
newItems := make([]models.Item, 0, len(i.resultSet.Items())+len(otherRS.resultSet.Items()))
for _, item := range i.resultSet.Items() {
pk, sk := item.PKSK(i.resultSet.TableInfo)
itemsInI[pksk{pk, sk}] = item
newItems = append(newItems, item)
}
for _, item := range otherRS.resultSet.Items() {
pk, sk := item.PKSK(i.resultSet.TableInfo)
if _, hasItem := itemsInI[pksk{pk, sk}]; !hasItem {
newItems = append(newItems, item)
}
}
newResultSet := &models.ResultSet{
Created: time.Now(),
TableInfo: i.resultSet.TableInfo,
}
newResultSet.SetItems(newItems)
return &resultSetProxy{resultSet: newResultSet}
}
type itemProxy struct {
resultSetProxy *resultSetProxy
itemIndex int
item models.Item
}
func (i *itemProxy) SetAttr(name string, value object.Object) error {
return errors.Errorf("attribute error: %v", name)
}
func (i *itemProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object {
return object.Errorf("op error: unsupported %v", opType)
}
func (i *itemProxy) Cost() int {
return len(i.item)
}
func newItemProxy(rs *resultSetProxy, itemIndex int) *itemProxy {
return &itemProxy{
resultSetProxy: rs,
itemIndex: itemIndex,
item: rs.resultSet.Items()[itemIndex],
}
}
func (i *itemProxy) Interface() interface{} {
return i.item
}
func (i *itemProxy) IsTruthy() bool {
return true
}
func (i *itemProxy) Type() object.Type {
return "item"
}
func (i *itemProxy) Inspect() string {
return "item"
}
func (i *itemProxy) Equals(other object.Object) object.Object {
// TODO
return object.False
}
func (i *itemProxy) GetAttr(name string) (object.Object, bool) {
// TODO: this should implement the container interface
switch name {
case "result_set":
return i.resultSetProxy, true
case "index":
return object.NewInt(int64(i.itemIndex)), true
case "attr":
return object.NewBuiltin("attr", i.value), true
case "set_attr":
return object.NewBuiltin("set_attr", i.setValue), true
case "delete_attr":
return object.NewBuiltin("delete_attr", i.deleteAttr), true
}
return nil, false
}
func (i *itemProxy) value(ctx context.Context, args ...object.Object) object.Object {
if objErr := require("item.attr", 1, args); objErr != nil {
return objErr
}
str, objErr := object.AsString(args[0])
if objErr != nil {
return objErr
}
modExpr, err := queryexpr.Parse(str)
if err != nil {
return object.Errorf("arg error: invalid path expression: %v", err)
}
av, err := modExpr.EvalItem(i.item)
if err != nil {
return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err))
}
tVal, err := attributeValueToTamarin(av)
if err != nil {
return object.NewError(err)
}
return tVal
}
func (i *itemProxy) setValue(ctx context.Context, args ...object.Object) object.Object {
if objErr := require("item.set_attr", 2, args); objErr != nil {
return objErr
}
pathExpr, objErr := object.AsString(args[0])
if objErr != nil {
return objErr
}
path, err := queryexpr.Parse(pathExpr)
if err != nil {
return object.Errorf("arg error: invalid path expression: %v", err)
}
newValue, err := tamarinValueToAttributeValue(args[1])
if err != nil {
return object.NewError(err)
}
if err := path.SetEvalItem(i.item, newValue); err != nil {
return object.NewError(err)
}
i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true)
return nil
}
func (i *itemProxy) deleteAttr(ctx context.Context, args ...object.Object) object.Object {
if objErr := require("item.delete_attr", 1, args); objErr != nil {
return objErr
}
str, objErr := object.AsString(args[0])
if objErr != nil {
return objErr
}
modExpr, err := queryexpr.Parse(str)
if err != nil {
return object.Errorf("arg error: invalid path expression: %v", err)
}
if err := modExpr.DeleteAttribute(i.item); err != nil {
return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err))
}
i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true)
return nil
}

View file

@ -1,355 +0,0 @@
package scriptmanager_test
import (
"context"
"testing"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestResultSetProxy(t *testing.T) {
t.Run("should property return properties of a resultset and item", func(t *testing.T) {
rs := &models.ResultSet{
TableInfo: &models.TableInfo{
Name: "test-table",
},
}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
// Test properties of the result set
assert(res.table.name, "hello")
assert(res == res, "result_set.equals")
assert(res.length == 2, "result_set.length")
// Test properties of items
assert(res[0].index == 0, "res[0].index")
assert(res[0].result_set == res, "res[0].result_set")
assert(res[0].attr('pk') == 'abc', "res[0].attr('pk')")
assert(res[1].attr('pk') == '1232', "res[1].attr('pk')")
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
}
func TestResultSetProxy_Find(t *testing.T) {
t.Run("should return the first item that matches the given expression", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "abc"}, "primary": &types.AttributeValueMemberS{Value: "yes"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}, "findMe": &types.AttributeValueMemberS{Value: "yes"}},
{"pk": &types.AttributeValueMemberS{Value: "2345"}, "findMe": &types.AttributeValueMemberS{Value: "second"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
assert(res.find('findMe is "any"').attr("pk") == "1232")
assert(res.find('findMe = "second"').attr("pk") == "2345")
assert(res.find('pk = sk').attr("primary") == "yes")
assert(res.find('findMe = "missing"') == nil)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
}
func TestResultSetProxy_Merge(t *testing.T) {
t.Run("should return a result set with items from both if both are from the same table", func(t *testing.T) {
td := &models.TableInfo{Name: "test", Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}}
rs1 := &models.ResultSet{TableInfo: td}
rs1.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "123"}},
})
rs2 := &models.ResultSet{TableInfo: td}
rs2.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "bcd"}, "sk": &types.AttributeValueMemberS{Value: "234"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "rs1", scriptmanager.QueryOptions{}).Return(rs1, nil)
mockedSessionService.EXPECT().Query(mock.Anything, "rs2", scriptmanager.QueryOptions{}).Return(rs2, nil)
testFS := testScriptFile(t, "test.tm", `
r1 := session.query("rs1")
r2 := session.query("rs2")
res := r1.merge(r2)
assert(res[0].attr("pk") == "abc")
assert(res[0].attr("sk") == "123")
assert(res[1].attr("pk") == "bcd")
assert(res[1].attr("sk") == "234")
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
t.Run("should return nil if result-sets are from different tables", func(t *testing.T) {
td1 := &models.TableInfo{Name: "test", Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}}
rs1 := &models.ResultSet{TableInfo: td1}
rs1.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}, "sk": &types.AttributeValueMemberS{Value: "123"}},
})
td2 := &models.TableInfo{Name: "test2", Keys: models.KeyAttribute{PartitionKey: "pk2", SortKey: "sk"}}
rs2 := &models.ResultSet{TableInfo: td2}
rs2.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "bcd"}, "sk": &types.AttributeValueMemberS{Value: "234"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "rs1", scriptmanager.QueryOptions{}).Return(rs1, nil)
mockedSessionService.EXPECT().Query(mock.Anything, "rs2", scriptmanager.QueryOptions{}).Return(rs2, nil)
testFS := testScriptFile(t, "test.tm", `
r1 := session.query("rs1")
r2 := session.query("rs2")
res := r1.merge(r2)
assert(res == nil)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
}
func TestResultSetProxy_GetAttr(t *testing.T) {
t.Run("should return the value of items within a result set", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{
"pk": &types.AttributeValueMemberS{Value: "abc"},
"sk": &types.AttributeValueMemberN{Value: "123"},
"bool": &types.AttributeValueMemberBOOL{Value: true},
"null": &types.AttributeValueMemberNULL{Value: true},
"list": &types.AttributeValueMemberL{Value: []types.AttributeValue{
&types.AttributeValueMemberS{Value: "apple"},
&types.AttributeValueMemberS{Value: "banana"},
&types.AttributeValueMemberS{Value: "cherry"},
}},
"map": &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{
"this": &types.AttributeValueMemberS{Value: "that"},
"another": &types.AttributeValueMemberS{Value: "thing"},
}},
"strSet": &types.AttributeValueMemberSS{Value: []string{"apple", "banana", "cherry"}},
"numSet": &types.AttributeValueMemberNS{Value: []string{"123", "45.67", "8.911", "-321"}},
},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
assert(res[0].attr("pk") == "abc", "str attr")
assert(res[0].attr("sk") == 123, "num attr")
assert(res[0].attr("bool") == true, "bool attr")
assert(res[0].attr("null") == nil, "null attr")
assert(res[0].attr("list") == ["apple","banana","cherry"], "list attr")
assert(res[0].attr("map") == {"this":"that", "another":"thing"}, "map attr")
assert(res[0].attr("strSet") == {"apple","banana","cherry"}, "string set")
assert(res[0].attr("numSet") == {123, 45.67, 8.911, -321}, "number set")
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedSessionService.AssertExpectations(t)
})
}
func TestResultSetProxy_SetAttr(t *testing.T) {
t.Run("should set the value of the item within a result set", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool {
assert.Equal(t, "bla-di-bla", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value)
assert.Equal(t, "123", rs.Items()[0]["num"].(*types.AttributeValueMemberN).Value)
assert.Equal(t, "123.45", rs.Items()[0]["numFloat"].(*types.AttributeValueMemberN).Value)
assert.Equal(t, true, rs.Items()[0]["bool"].(*types.AttributeValueMemberBOOL).Value)
assert.Equal(t, true, rs.Items()[0]["nil"].(*types.AttributeValueMemberNULL).Value)
list := rs.Items()[0]["lists"].(*types.AttributeValueMemberL).Value
assert.Equal(t, "abc", list[0].(*types.AttributeValueMemberS).Value)
assert.Equal(t, "123", list[1].(*types.AttributeValueMemberN).Value)
assert.Equal(t, true, list[2].(*types.AttributeValueMemberBOOL).Value)
nestedLists := rs.Items()[0]["nestedLists"].(*types.AttributeValueMemberL).Value
assert.Equal(t, "1", nestedLists[0].(*types.AttributeValueMemberL).Value[0].(*types.AttributeValueMemberN).Value)
assert.Equal(t, "2", nestedLists[0].(*types.AttributeValueMemberL).Value[1].(*types.AttributeValueMemberN).Value)
assert.Equal(t, "3", nestedLists[1].(*types.AttributeValueMemberL).Value[0].(*types.AttributeValueMemberN).Value)
assert.Equal(t, "4", nestedLists[1].(*types.AttributeValueMemberL).Value[1].(*types.AttributeValueMemberN).Value)
mapValue := rs.Items()[0]["map"].(*types.AttributeValueMemberM).Value
assert.Equal(t, "world", mapValue["hello"].(*types.AttributeValueMemberS).Value)
assert.Equal(t, "213", mapValue["nums"].(*types.AttributeValueMemberN).Value)
numSet := rs.Items()[0]["numSet"].(*types.AttributeValueMemberNS).Value
assert.Len(t, numSet, 4)
assert.Contains(t, numSet, "1")
assert.Contains(t, numSet, "2")
assert.Contains(t, numSet, "3")
assert.Contains(t, numSet, "4.5")
strSet := rs.Items()[0]["strSet"].(*types.AttributeValueMemberSS).Value
assert.Len(t, strSet, 3)
assert.Contains(t, strSet, "a")
assert.Contains(t, strSet, "b")
assert.Contains(t, strSet, "c")
assert.True(t, rs.IsDirty(0))
return true
}))
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
res[0].set_attr("pk", "bla-di-bla")
res[0].set_attr("num", 123)
res[0].set_attr("numFloat", 123.45)
res[0].set_attr("bool", true)
res[0].set_attr("nil", nil)
res[0].set_attr("lists", ['abc', 123, true])
res[0].set_attr("nestedLists", [[1,2], [3,4]])
res[0].set_attr("map", {"hello": "world", "nums": 213})
res[0].set_attr("numSet", {1,2,3,4.5})
res[0].set_attr("strSet", {"a","b","c"})
session.set_result_set(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
}
func TestResultSetProxy_DeleteAttr(t *testing.T) {
t.Run("should delete the value of the item within a result set", func(t *testing.T) {
rs := &models.ResultSet{}
rs.SetItems([]models.Item{
{"pk": &types.AttributeValueMemberS{Value: "abc"}, "deleteMe": &types.AttributeValueMemberBOOL{Value: true}},
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
})
mockedSessionService := mocks.NewSessionService(t)
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool {
assert.Equal(t, "abc", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value)
assert.Nil(t, rs.Items()[0]["deleteMe"])
assert.True(t, rs.IsDirty(0))
return true
}))
mockedUIService := mocks.NewUIService(t)
testFS := testScriptFile(t, "test.tm", `
res := session.query("some expr")
res[0].delete_attr("deleteMe")
session.set_result_set(res)
`)
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
Session: mockedSessionService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
mockedSessionService.AssertExpectations(t)
})
}

View file

@ -1,53 +0,0 @@
package scriptmanager
import (
"context"
"github.com/pkg/errors"
"time"
)
type scriptScheduler struct {
jobChan chan scriptJob
}
func newScriptScheduler() *scriptScheduler {
ss := &scriptScheduler{}
ss.start()
return ss
}
func (ss *scriptScheduler) start() {
ss.jobChan = make(chan scriptJob)
go func() {
for job := range ss.jobChan {
job.job(job.ctx)
}
}()
}
// startJobOnceFree will submit a script execution job. The function will wait until the scheduler is free.
// The job will then run on the script goroutine and the function will return.
func (ss *scriptScheduler) startJobOnceFree(ctx context.Context, job func(ctx context.Context)) error {
select {
case ss.jobChan <- scriptJob{ctx: ctx, job: job}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// runNow will submit a job for immediate execution. The job will run as long as the scheduler is free.
// If the scheduler is not free, an error will be returned and the job will not run.
func (ss *scriptScheduler) runNow(ctx context.Context, job func(ctx context.Context)) error {
select {
case ss.jobChan <- scriptJob{ctx: ctx, job: job}:
return nil
case <-time.After(500 * time.Millisecond):
return errors.New("a script is already running")
}
}
type scriptJob struct {
ctx context.Context
job func(ctx context.Context)
}

View file

@ -1,250 +0,0 @@
package scriptmanager
import (
"context"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings"
"github.com/pkg/errors"
"github.com/risor-io/risor"
"github.com/risor-io/risor/object"
)
var (
relPrefix = "." + string(filepath.Separator)
)
type Service struct {
lookupPaths []fs.FS
ifaces Ifaces
sched *scriptScheduler
plugins []*ScriptPlugin
}
func New(opts ...ServiceOption) *Service {
srv := &Service{
lookupPaths: nil,
sched: newScriptScheduler(),
}
for _, opt := range opts {
opt(srv)
}
return srv
}
func (s *Service) SetLookupPaths(fs []fs.FS) {
s.lookupPaths = fs
}
func (s *Service) SetIFaces(ifaces Ifaces) {
s.ifaces = ifaces
}
func (s *Service) LoadScript(ctx context.Context, filename string) (*ScriptPlugin, error) {
resChan := make(chan loadedScriptResult)
if err := s.sched.startJobOnceFree(ctx, func(ctx context.Context) {
s.loadScript(ctx, filename, resChan)
}); err != nil {
return nil, err
}
res := <-resChan
if res.err != nil {
return nil, res.err
}
// Look for the previous version. If one is there, replace it, otherwise add it
// TODO: this should probably be protected by a mutex
newPlugin := res.scriptPlugin
for i, p := range s.plugins {
if p.name == newPlugin.name {
s.plugins[i] = newPlugin
return newPlugin, nil
}
}
s.plugins = append(s.plugins, newPlugin)
return newPlugin, nil
}
func (s *Service) RunAdHocScript(ctx context.Context, filename string) chan error {
errChan := make(chan error)
go s.startAdHocScript(ctx, filename, errChan)
return errChan
}
func (s *Service) StartAdHocScript(ctx context.Context, filename string, errChan chan error) error {
return s.sched.startJobOnceFree(ctx, func(ctx context.Context) {
s.startAdHocScript(ctx, filename, errChan)
})
}
func (s *Service) startAdHocScript(ctx context.Context, filename string, errChan chan error) {
defer close(errChan)
code, err := s.readScript(filename, true)
if err != nil {
errChan <- errors.Wrapf(err, "cannot load script file %v", filename)
return
}
ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename)})
if _, err := risor.Eval(ctx, code,
risor.WithGlobals(s.builtins()),
); err != nil {
errChan <- errors.Wrapf(err, "script %v", filename)
return
}
}
type loadedScriptResult struct {
scriptPlugin *ScriptPlugin
err error
}
func (s *Service) loadScript(ctx context.Context, filename string, resChan chan loadedScriptResult) {
defer close(resChan)
code, err := s.readScript(filename, false)
if err != nil {
resChan <- loadedScriptResult{err: errors.Wrapf(err, "cannot load script file %v", filename)}
return
}
newPlugin := &ScriptPlugin{
name: strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)),
scriptService: s,
}
ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename)})
if _, err := risor.Eval(ctx, code,
risor.WithGlobals(s.builtins()),
risor.WithGlobals(map[string]any{
"ext": (&extModule{scriptPlugin: newPlugin}).register(),
}),
); err != nil {
resChan <- loadedScriptResult{err: errors.Wrapf(err, "script %v", filename)}
return
}
resChan <- loadedScriptResult{scriptPlugin: newPlugin}
}
func (s *Service) readScript(filename string, allowCwd bool) (string, error) {
if allowCwd {
if cwd, err := os.Getwd(); err == nil {
fullScriptPath := filepath.Join(cwd, filename)
log.Printf("checking %v", fullScriptPath)
if stat, err := os.Stat(fullScriptPath); err == nil && !stat.IsDir() {
code, err := os.ReadFile(filename)
if err != nil {
return "", err
}
return string(code), nil
}
} else {
log.Printf("warn: cannot get cwd for reading script %v: %v", filename, err)
}
}
if strings.HasPrefix(filename, string(filepath.Separator)) || strings.HasPrefix(filename, relPrefix) {
code, err := os.ReadFile(filename)
if err != nil {
return "", err
}
return string(code), nil
}
for _, currFS := range s.lookupPaths {
log.Printf("checking %v/%v", currFS, filename)
stat, err := fs.Stat(currFS, filename)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
} else {
return "", err
}
} else if stat.IsDir() {
continue
}
code, err := fs.ReadFile(currFS, filename)
if err == nil {
return string(code), nil
} else {
return "", err
}
}
return "", os.ErrNotExist
}
// LookupCommand looks up a command defined by a script.
// TODO: Command should probably accept/return a chan error to indicate that this will run in a separate goroutine
func (s *Service) LookupCommand(name string) *Command {
for _, p := range s.plugins {
if cmd, hasCmd := p.definedCommands[name]; hasCmd {
return cmd
}
}
return nil
}
func (s *Service) LookupKeyBinding(key string) (string, *Command) {
for _, p := range s.plugins {
if bindingName, hasBinding := p.keyToKeyBinding[key]; hasBinding {
if cmd, hasCmd := p.definedKeyBindings[bindingName]; hasCmd {
return bindingName, cmd
}
}
}
return "", nil
}
func (s *Service) UnbindKey(key string) {
for _, p := range s.plugins {
if _, hasBinding := p.keyToKeyBinding[key]; hasBinding {
delete(p.keyToKeyBinding, key)
}
}
}
func (s *Service) RebindKeyBinding(keyBinding string, newKey string) error {
if newKey == "" {
for _, p := range s.plugins {
for k, b := range p.keyToKeyBinding {
if b == keyBinding {
delete(p.keyToKeyBinding, k)
}
}
}
return nil
}
for _, p := range s.plugins {
if _, hasCmd := p.definedKeyBindings[keyBinding]; hasCmd {
if newKey != "" {
p.keyToKeyBinding[newKey] = keyBinding
}
return nil
}
}
return keybindings.InvalidBindingError(keyBinding)
}
func (s *Service) builtins() map[string]any {
return map[string]any{
"ui": (&uiModule{uiService: s.ifaces.UI}).register(),
"session": (&sessionModule{sessionService: s.ifaces.Session}).register(),
"print": object.NewBuiltin("print", printBuiltin),
"printf": object.NewBuiltin("printf", printfBuiltin),
}
}

View file

@ -1,150 +0,0 @@
package scriptmanager_test
import (
"context"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/scriptmanager/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"io/fs"
"testing"
"testing/fstest"
"time"
)
func TestService_RunAdHocScript(t *testing.T) {
t.Run("successfully loads and executes a script", func(t *testing.T) {
testFS := testScriptFile(t, "test.tm", `
ui.print("Hello, world")
`)
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
})
ctx := context.Background()
err := <-srv.RunAdHocScript(ctx, "test.tm")
assert.NoError(t, err)
mockedUIService.AssertExpectations(t)
})
}
func TestService_LoadScript(t *testing.T) {
t.Run("successfully loads a script and exposes it as a plugin", func(t *testing.T) {
testFS := testScriptFile(t, "test.tm", `
ext.command("somewhere", func(a) {
ui.print("Hello, " + a)
})
`)
ctx := context.Background()
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone")
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
})
plugin, err := srv.LoadScript(ctx, "test.tm")
assert.NoError(t, err)
assert.NotNil(t, plugin)
assert.Equal(t, "test", plugin.Name())
cmd := srv.LookupCommand("somewhere")
assert.NotNil(t, cmd)
errChan := make(chan error)
err = cmd.Invoke(ctx, []string{"someone"}, errChan)
assert.NoError(t, err)
assert.NoError(t, waitForErr(t, errChan))
mockedUIService.AssertExpectations(t)
})
t.Run("reloading a script with the same name should remove the old one", func(t *testing.T) {
testFS := fstest.MapFS{
"test.tm": &fstest.MapFile{
Data: []byte(`
ext.command("somewhere", func(a) {
ui.print("Hello, " + a)
})
`),
},
}
ctx := context.Background()
mockedUIService := mocks.NewUIService(t)
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone").Once()
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Goodbye, someone").Once()
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
srv.SetIFaces(scriptmanager.Ifaces{
UI: mockedUIService,
})
// Execute the old script
_, err := srv.LoadScript(ctx, "test.tm")
assert.NoError(t, err)
cmd := srv.LookupCommand("somewhere")
assert.NotNil(t, cmd)
errChan := make(chan error)
err = cmd.Invoke(ctx, []string{"someone"}, errChan)
assert.NoError(t, err)
assert.NoError(t, waitForErr(t, errChan))
// Change the script and reload
testFS["test.tm"] = &fstest.MapFile{
Data: []byte(`
ext.command("somewhere", func(a) {
ui.print("Goodbye, " + a)
})
`),
}
_, err = srv.LoadScript(ctx, "test.tm")
assert.NoError(t, err)
cmd = srv.LookupCommand("somewhere")
assert.NotNil(t, cmd)
errChan = make(chan error)
err = cmd.Invoke(ctx, []string{"someone"}, errChan)
assert.NoError(t, err)
assert.NoError(t, waitForErr(t, errChan))
mockedUIService.AssertExpectations(t)
})
}
func testScriptFile(t *testing.T, filename, code string) fs.FS {
t.Helper()
testFs := fstest.MapFS{
filename: &fstest.MapFile{
Data: []byte(code),
},
}
return testFs
}
func waitForErr(t *testing.T, errChan chan error) error {
t.Helper()
select {
case err := <-errChan:
return err
case <-time.After(5 * time.Second):
t.Fatalf("timed-out waiting for an error")
}
return nil
}

View file

@ -1,11 +0,0 @@
package scriptmanager
import "io/fs"
type ServiceOption func(srv *Service)
func WithFS(fs ...fs.FS) ServiceOption {
return func(srv *Service) {
srv.lookupPaths = fs
}
}

View file

@ -1,138 +0,0 @@
package scriptmanager
import (
"github.com/lmika/dynamo-browse/internal/common/sliceutils"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/pkg/errors"
"github.com/risor-io/risor/object"
"github.com/risor-io/risor/op"
"reflect"
)
const (
tableProxyPartitionKey = "hash"
tableProxySortKey = "range"
)
type tableProxy struct {
table *models.TableInfo
}
func (t *tableProxy) SetAttr(name string, value object.Object) error {
return errors.Errorf("attribute error: %v", name)
}
func (t *tableProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object {
return object.Errorf("op error: unsupported %v", opType)
}
func (t *tableProxy) Cost() int {
return 0
}
func (t *tableProxy) Type() object.Type {
return "table"
}
func (t *tableProxy) Inspect() string {
return "table(" + t.table.Name + ")"
}
func (t *tableProxy) Interface() interface{} {
return t.table
}
func (t *tableProxy) Equals(other object.Object) object.Object {
otherT, isOtherRS := other.(*tableProxy)
if !isOtherRS {
return object.False
}
return object.NewBool(reflect.DeepEqual(t.table, otherT.table))
}
func (t *tableProxy) GetAttr(name string) (object.Object, bool) {
switch name {
case "name":
return object.NewString(t.table.Name), true
case "keys":
if t.table.Keys.SortKey == "" {
return object.NewMap(map[string]object.Object{
tableProxyPartitionKey: object.NewString(t.table.Keys.PartitionKey),
tableProxySortKey: object.Nil,
}), true
}
return object.NewMap(map[string]object.Object{
tableProxyPartitionKey: object.NewString(t.table.Keys.PartitionKey),
tableProxySortKey: object.NewString(t.table.Keys.SortKey),
}), true
case "gsis":
return object.NewList(sliceutils.Map(t.table.GSIs, newTableIndexProxy)), true
}
return nil, false
}
func (t *tableProxy) IsTruthy() bool {
return true
}
type tableIndexProxy struct {
gsi models.TableGSI
}
func (t tableIndexProxy) SetAttr(name string, value object.Object) error {
return errors.Errorf("attribute error: %v", name)
}
func (t tableIndexProxy) RunOperation(opType op.BinaryOpType, right object.Object) object.Object {
return object.Errorf("op error: unsupported %v", opType)
}
func (t tableIndexProxy) Cost() int {
return 0
}
func newTableIndexProxy(gsi models.TableGSI) object.Object {
return tableIndexProxy{gsi: gsi}
}
func (t tableIndexProxy) Type() object.Type {
return "table_index"
}
func (t tableIndexProxy) Inspect() string {
return "table_index(gsi," + t.gsi.Name + ")"
}
func (t tableIndexProxy) Interface() interface{} {
return t.gsi
}
func (t tableIndexProxy) Equals(other object.Object) object.Object {
otherIP, isOtherIP := other.(tableIndexProxy)
if !isOtherIP {
return object.False
}
return object.NewBool(reflect.DeepEqual(t.gsi, otherIP.gsi))
}
func (t tableIndexProxy) GetAttr(name string) (object.Object, bool) {
switch name {
case "name":
return object.NewString(t.gsi.Name), true
case "keys":
return object.NewMap(map[string]object.Object{
tableProxyPartitionKey: object.NewString(t.gsi.Keys.PartitionKey),
tableProxySortKey: object.NewString(t.gsi.Keys.SortKey),
}), true
}
return nil, false
}
func (t tableIndexProxy) IsTruthy() bool {
return true
}

View file

@ -1,135 +0,0 @@
package scriptmanager
import (
"fmt"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/lmika/dynamo-browse/internal/common/maputils"
"github.com/lmika/dynamo-browse/internal/common/sliceutils"
"github.com/pkg/errors"
"github.com/risor-io/risor/object"
"regexp"
"strconv"
)
func tamarinValueToAttributeValue(val object.Object) (types.AttributeValue, error) {
switch v := val.(type) {
case *object.String:
return &types.AttributeValueMemberS{Value: v.Value()}, nil
case *object.Int:
return &types.AttributeValueMemberN{Value: strconv.FormatInt(v.Value(), 10)}, nil
case *object.Float:
return &types.AttributeValueMemberN{Value: strconv.FormatFloat(v.Value(), 'f', -1, 64)}, nil
case *object.Bool:
return &types.AttributeValueMemberBOOL{Value: v.Value()}, nil
case *object.NilType:
return &types.AttributeValueMemberNULL{Value: true}, nil
case *object.List:
attrValue, err := sliceutils.MapWithError(v.Value(), tamarinValueToAttributeValue)
if err != nil {
return nil, err
}
return &types.AttributeValueMemberL{Value: attrValue}, nil
case *object.Map:
attrValue, err := maputils.MapValuesWithError(v.Value(), tamarinValueToAttributeValue)
if err != nil {
return nil, err
}
return &types.AttributeValueMemberM{Value: attrValue}, nil
case *object.Set:
values := maputils.Values(v.Value())
canBeNumSet := sliceutils.All(values, func(t object.Object) bool {
_, isInt := t.(*object.Int)
_, isFloat := t.(*object.Float)
return isInt || isFloat
})
if canBeNumSet {
return &types.AttributeValueMemberNS{
Value: sliceutils.Map(values, func(t object.Object) string {
switch v := t.(type) {
case *object.Int:
return strconv.FormatInt(v.Value(), 10)
case *object.Float:
return strconv.FormatFloat(v.Value(), 'f', -1, 64)
}
panic(fmt.Sprintf("unhandled object type: %v", t.Type()))
}),
}, nil
}
return &types.AttributeValueMemberSS{
Value: sliceutils.Map(values, func(t object.Object) string {
v, _ := object.AsString(t)
return v
}),
}, nil
}
return nil, errors.Errorf("type error: unsupported value type (got %v)", val.Type())
}
func attributeValueToTamarin(val types.AttributeValue) (object.Object, error) {
if val == nil {
return object.Nil, nil
}
switch v := val.(type) {
case *types.AttributeValueMemberS:
return object.NewString(v.Value), nil
case *types.AttributeValueMemberN:
f, err := convertNumAttributeToTamarinValue(v.Value)
if err != nil {
return nil, errors.Errorf("value error: invalid N value: %v", v.Value)
}
return f, nil
case *types.AttributeValueMemberBOOL:
if v.Value {
return object.True, nil
}
return object.False, nil
case *types.AttributeValueMemberNULL:
return object.Nil, nil
case *types.AttributeValueMemberL:
list, err := sliceutils.MapWithError(v.Value, attributeValueToTamarin)
if err != nil {
return nil, err
}
return object.NewList(list), nil
case *types.AttributeValueMemberM:
objMap, err := maputils.MapValuesWithError(v.Value, attributeValueToTamarin)
if err != nil {
return nil, err
}
return object.NewMap(objMap), nil
case *types.AttributeValueMemberSS:
return object.NewSet(sliceutils.Map(v.Value, func(s string) object.Object {
return object.NewString(s)
})), nil
case *types.AttributeValueMemberNS:
nums, err := sliceutils.MapWithError(v.Value, func(s string) (object.Object, error) {
return convertNumAttributeToTamarinValue(s)
})
if err != nil {
return nil, err
}
return object.NewSet(nums), nil
}
return nil, errors.Errorf("value error: cannot convert type %T to tamarin object", val)
}
var intNumberPattern = regexp.MustCompile(`^[-]?[0-9]+$`)
// XXX - this is pretty crappy in that it does not support large values
func convertNumAttributeToTamarinValue(n string) (object.Object, error) {
if intNumberPattern.MatchString(n) {
parsedInt, err := strconv.ParseInt(n, 10, 64)
if err != nil {
return nil, err
}
return object.NewInt(parsedInt), nil
}
f, err := strconv.ParseFloat(n, 64)
if err != nil {
return nil, err
}
return object.NewFloat(f), nil
}

View file

@ -1,35 +0,0 @@
package scriptmanager
import (
"context"
)
type ScriptPlugin struct {
scriptService *Service
name string
definedCommands map[string]*Command
definedKeyBindings map[string]*Command
keyToKeyBinding map[string]string
relatedItems []*relatedItemBuilder
}
func (sp *ScriptPlugin) Name() string {
return sp.name
}
type Command struct {
plugin *ScriptPlugin
cmdFn func(ctx context.Context, args []string) error
}
// Invoke will schedule the command for invocation. If the script scheduler is free, it will be started immediately.
// Otherwise an error will be returned.
func (c *Command) Invoke(ctx context.Context, args []string, errChan chan error) error {
return c.plugin.scriptService.sched.runNow(ctx, func(ctx context.Context) {
errChan <- c.cmdFn(ctx, args)
})
}
//func (c *Command) LookupRelevantItems(ctx context.Context, table *models.TableInfo, item *models.Item) error {
//
//}

View file

@ -1,16 +1,11 @@
package ui
import (
"log"
"os"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/lmika/dynamo-browse/internal/common/ui/commandctrl"
"github.com/lmika/dynamo-browse/internal/common/ui/events"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/controllers"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/itemrenderer"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/keybindings"
@ -26,7 +21,7 @@ import (
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/tableselect"
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
bus "github.com/lmika/events"
"github.com/pkg/errors"
"log"
)
const (
@ -37,8 +32,6 @@ const (
ViewModeTableOnly = 4
ViewModeCount = 5
initRCFilename = "$HOME/.config/audax/dynamo-browse/init.rc"
)
type Model struct {
@ -47,7 +40,6 @@ type Model struct {
settingsController *controllers.SettingsController
exportController *controllers.ExportController
commandController *commandctrl.CommandController
scriptController *controllers.ScriptController
jobController *controllers.JobsController
colSelector *colselector.Model
relSelector *relselector.Model
@ -75,7 +67,6 @@ func NewModel(
jobController *controllers.JobsController,
itemRendererService *itemrenderer.Service,
cc *commandctrl.CommandController,
scriptController *controllers.ScriptController,
eventBus *bus.Bus,
keyBindingController *controllers.KeyBindingController,
pasteboardProvider services.PasteboardProvider,
@ -94,157 +85,13 @@ func NewModel(
dialogPrompt := dialogprompt.New(statusAndPrompt)
tableSelect := tableselect.New(dialogPrompt, uiStyles)
cc.AddCommands(&commandctrl.CommandList{
Commands: map[string]commandctrl.Command{
"quit": commandctrl.NoArgCommand(tea.Quit),
"table": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
if len(args) == 0 {
return rc.ListTables(false)
} else {
return rc.ScanTable(args[0])
}
},
"export": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
if len(args) == 0 {
return events.Error(errors.New("expected filename"))
}
opts := controllers.ExportOptions{}
if len(args) == 2 && args[0] == "-all" {
opts.AllResults = true
args = args[1:]
}
return exportController.ExportCSV(args[0], opts)
},
"mark": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
var markOp = controllers.MarkOpMark
if len(args) > 0 {
switch args[0] {
case "all":
markOp = controllers.MarkOpMark
case "none":
markOp = controllers.MarkOpUnmark
case "toggle":
markOp = controllers.MarkOpToggle
default:
return events.Error(errors.New("unrecognised mark operation"))
}
}
var whereExpr = ""
if len(args) == 3 && args[1] == "-where" {
whereExpr = args[2]
}
return rc.Mark(markOp, whereExpr)
},
"next-page": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
return rc.NextPage()
},
"delete": commandctrl.NoArgCommand(wc.DeleteMarked),
// TEMP
"new-item": commandctrl.NoArgCommand(wc.NewItem),
"clone": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
return wc.CloneItem(dtv.SelectedItemIndex())
},
"set-attr": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
if len(args) == 0 {
return events.Error(errors.New("expected field"))
}
var itemType = models.UnsetItemType
if len(args) == 2 {
switch strings.ToUpper(args[0]) {
case "-S":
itemType = models.StringItemType
case "-N":
itemType = models.NumberItemType
case "-BOOL":
itemType = models.BoolItemType
case "-NULL":
itemType = models.NullItemType
case "-TO":
itemType = models.ExprValueItemType
default:
return events.Error(errors.New("unrecognised item type"))
}
args = args[1:]
}
return wc.SetAttributeValue(dtv.SelectedItemIndex(), itemType, args[0])
},
"del-attr": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
if len(args) == 0 {
return events.Error(errors.New("expected field"))
}
return wc.DeleteAttribute(dtv.SelectedItemIndex(), args[0])
},
"put": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
return wc.PutItems()
},
"touch": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
return wc.TouchItem(dtv.SelectedItemIndex())
},
"noisy-touch": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
return wc.NoisyTouchItem(dtv.SelectedItemIndex())
},
"echo": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
s := new(strings.Builder)
for _, arg := range args {
s.WriteString(arg)
}
return events.StatusMsg(s.String())
},
"set": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
switch len(args) {
case 1:
return settingsController.SetSetting(args[0], "")
case 2:
return settingsController.SetSetting(args[0], args[1])
}
return events.Error(errors.New("expected: settingName [value]"))
},
"rebind": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
if len(args) != 2 {
return events.Error(errors.New("expected: bindingName newKey"))
}
return keyBindingController.Rebind(args[0], args[1], ctx.FromFile)
},
"run-script": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
if len(args) != 1 {
return events.Error(errors.New("expected: script name"))
}
return scriptController.RunScript(args[0])
},
"load-script": func(ctx commandctrl.ExecContext, args []string) tea.Msg {
if len(args) != 1 {
return events.Error(errors.New("expected: script name"))
}
return scriptController.LoadScript(args[0])
},
// Aliases
"unmark": cc.Alias("mark", []string{"none"}),
"sa": cc.Alias("set-attr", nil),
"da": cc.Alias("del-attr", nil),
"np": cc.Alias("next-page", nil),
"w": cc.Alias("put", nil),
"q": cc.Alias("quit", nil),
},
})
root := layout.FullScreen(tableSelect)
return Model{
tableReadController: rc,
tableWriteController: wc,
commandController: cc,
scriptController: scriptController,
exportController: exportController,
jobController: jobController,
itemEdit: itemEdit,
colSelector: colSelector,
@ -265,10 +112,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case controllers.SetTableItemView:
cmd := m.setMainViewIndex(msg.ViewIndex)
return m, cmd
case controllers.ResultSetUpdated:
case events.ResultSetUpdated:
return m, tea.Batch(
m.tableView.Refresh(),
events.SetStatus(msg.StatusMessage()),
events.SetStatus(msg.StatusMessage),
)
case tea.KeyMsg:
// TODO: use modes here
@ -291,7 +138,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keyMap.PromptForQuery):
return m, m.tableReadController.PromptForQuery
case key.Matches(msg, m.keyMap.PromptForFilter):
return m, m.tableReadController.Filter
return m, m.tableReadController.PromptForFilter
case key.Matches(msg, m.keyMap.FetchNextPage):
return m, m.tableReadController.NextPage
case key.Matches(msg, m.keyMap.ViewBack):
@ -307,10 +154,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// return m, nil
case key.Matches(msg, m.keyMap.ShowColumnOverlay):
return m, events.SetTeaMessage(controllers.ShowColumnOverlay{})
case key.Matches(msg, m.keyMap.ShowRelItemsOverlay):
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
return m, events.SetTeaMessage(m.scriptController.LookupRelatedItems(idx))
}
//case key.Matches(msg, m.keyMap.ShowRelItemsOverlay):
// if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
// return m, events.SetTeaMessage(m.scriptController.LookupRelatedItems(idx))
// }
case key.Matches(msg, m.keyMap.PromptForCommand):
return m, m.commandController.Prompt
case key.Matches(msg, m.keyMap.PromptForTable):
@ -333,12 +180,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) Init() tea.Cmd {
// TODO: this should probably be moved somewhere else
rcFilename := os.ExpandEnv(initRCFilename)
if err := m.commandController.ExecuteFile(rcFilename); err != nil {
log.Println(err)
}
return tea.Batch(
m.tableReadController.Init,
m.root.Init(),
@ -382,3 +223,11 @@ func (m *Model) promptToQuit() tea.Msg {
return nil
})
}
func (m *Model) SelectedItemIndex() int {
return m.tableView.SelectedItemIndex()
}
func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg {
return m.tableView.SetSelectedItemIndex(newIdx)
}

View file

@ -208,6 +208,27 @@ func (m *Model) SelectedItemIndex() int {
return selectedItem.itemIndex
}
func (m *Model) SetSelectedItemIndex(newIdx int) tea.Msg {
cursor := m.table.Cursor()
switch {
case newIdx <= 0:
m.table.GoTop()
case newIdx >= len(m.rows)-1:
m.table.GoBottom()
case newIdx < cursor:
delta := cursor - newIdx
for d := 0; d < delta; d++ {
m.table.GoUp()
}
case newIdx > cursor:
delta := newIdx - cursor
for d := 0; d < delta; d++ {
m.table.GoDown()
}
}
return m.postSelectedItemChanged()
}
func (m *Model) selectedItem() (itemTableRow, bool) {
resultSet := m.resultSet