Converted scripting language Tamarin to Risor (#55)
- Converted Tamarin script language to Risor - Added a "find" and "merge" method to the result set script type. - Added the ability to copy the table of results to the pasteboard by pressing C - Added the -q flag, which will run a query and display the results as a CSV file on the command line - Upgraded Go to 1.21 in Github actions - Fix issue with missing limits - Added the '-where' switch to the mark - Added the 'marked' function to the query expression. - Added a sampled time and count on the right-side of the mode line - Added the 'M' key binding to toggle the marked items - Started working on tab completion for 'sa' and 'da' commands - Added count and sample time to the right-side of the mode line - Added Ctrl+V to the prompt to paste the text of the pasteboard with all whitespace characters trimmed - Fixed failing unit tests
This commit is contained in:
parent
ed53173a1d
commit
7ca0cf6982
54 changed files with 1227 additions and 281 deletions
|
|
@ -18,9 +18,10 @@ import (
|
|||
const commandsCategory = "commands"
|
||||
|
||||
type CommandController struct {
|
||||
historyProvider IterProvider
|
||||
commandList *CommandList
|
||||
lookupExtensions []CommandLookupExtension
|
||||
historyProvider IterProvider
|
||||
commandList *CommandList
|
||||
lookupExtensions []CommandLookupExtension
|
||||
completionProvider CommandCompletionProvider
|
||||
}
|
||||
|
||||
func NewCommandController(historyProvider IterProvider) *CommandController {
|
||||
|
|
@ -40,6 +41,10 @@ func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension
|
|||
c.lookupExtensions = append(c.lookupExtensions, ext)
|
||||
}
|
||||
|
||||
func (c *CommandController) SetCommandCompletionProvider(provider CommandCompletionProvider) {
|
||||
c.completionProvider = provider
|
||||
}
|
||||
|
||||
func (c *CommandController) Prompt() tea.Msg {
|
||||
return events.PromptForInputMsg{
|
||||
Prompt: ":",
|
||||
|
|
@ -47,6 +52,24 @@ func (c *CommandController) Prompt() tea.Msg {
|
|||
OnDone: func(value string) tea.Msg {
|
||||
return c.Execute(value)
|
||||
},
|
||||
// TEMP
|
||||
OnTabComplete: func(value string) (string, bool) {
|
||||
if c.completionProvider == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "sa ") || strings.HasPrefix(value, "da ") {
|
||||
tokens := shellwords.Split(strings.TrimSpace(value))
|
||||
lastToken := tokens[len(tokens)-1]
|
||||
|
||||
options := c.completionProvider.AttributesWithPrefix(lastToken)
|
||||
if len(options) == 1 {
|
||||
return value[:len(value)-len(lastToken)] + options[0], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
// END TEMP
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,7 @@ type CommandList struct {
|
|||
type CommandLookupExtension interface {
|
||||
LookupCommand(name string) Command
|
||||
}
|
||||
|
||||
type CommandCompletionProvider interface {
|
||||
AttributesWithPrefix(prefix string) []string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,3 +54,8 @@ type MessageWithMode interface {
|
|||
MessageWithStatus
|
||||
ModeMessage() string
|
||||
}
|
||||
|
||||
type MessageWithRightMode interface {
|
||||
MessageWithStatus
|
||||
RightModeMessage() string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ type ModeMessage string
|
|||
|
||||
// PromptForInput indicates that the context is requesting a line of input
|
||||
type PromptForInputMsg struct {
|
||||
Prompt string
|
||||
History services.HistoryProvider
|
||||
OnDone func(value string) tea.Msg
|
||||
OnCancel func() tea.Msg
|
||||
Prompt string
|
||||
History services.HistoryProvider
|
||||
OnDone func(value string) tea.Msg
|
||||
OnCancel func() tea.Msg
|
||||
OnTabComplete func(value string) (string, bool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/columns"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
|
||||
bus "github.com/lmika/events"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ColumnsController struct {
|
||||
|
|
@ -115,3 +116,13 @@ func (cc *ColumnsController) DeleteColumn(afterIndex int) tea.Msg {
|
|||
|
||||
return ColumnsUpdated{}
|
||||
}
|
||||
|
||||
func (c *ColumnsController) AttributesWithPrefix(prefix string) []string {
|
||||
options := make([]string, 0)
|
||||
for _, col := range c.resultSet.Columns() {
|
||||
if strings.HasPrefix(col, prefix) {
|
||||
options = append(options, col)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SetTableItemView struct {
|
||||
|
|
@ -42,6 +44,24 @@ func (rs NewResultSet) ModeMessage() string {
|
|||
return modeLine
|
||||
}
|
||||
|
||||
func (rs NewResultSet) RightModeMessage() string {
|
||||
var sb strings.Builder
|
||||
|
||||
itemCountStr := applyToN("", len(rs.ResultSet.Items()), "item", "items", "")
|
||||
if rs.currentFilter != "" {
|
||||
sb.WriteString(fmt.Sprintf("%d of %v", rs.filteredCount, itemCountStr))
|
||||
} else {
|
||||
sb.WriteString(itemCountStr)
|
||||
}
|
||||
|
||||
if !rs.ResultSet.Created.IsZero() {
|
||||
sb.WriteString(" • ")
|
||||
sb.WriteString(rs.ResultSet.Created.Format(time.Kitchen))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (rs NewResultSet) StatusMessage() string {
|
||||
if rs.statusMessage != "" {
|
||||
return rs.statusMessage
|
||||
|
|
|
|||
|
|
@ -1,26 +1,39 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"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/attrutils"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/columns"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/jobs"
|
||||
"github.com/pkg/errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ExportController struct {
|
||||
state *State
|
||||
tableService TableReadService
|
||||
jobController *JobsController
|
||||
columns *ColumnsController
|
||||
state *State
|
||||
tableService TableReadService
|
||||
jobController *JobsController
|
||||
columns *ColumnsController
|
||||
pasteboardProvider services.PasteboardProvider
|
||||
}
|
||||
|
||||
func NewExportController(state *State, tableService TableReadService, jobsController *JobsController, columns *ColumnsController) *ExportController {
|
||||
return &ExportController{state, tableService, jobsController, columns}
|
||||
func NewExportController(
|
||||
state *State,
|
||||
tableService TableReadService,
|
||||
jobsController *JobsController,
|
||||
columns *ColumnsController,
|
||||
pasteboardProvider services.PasteboardProvider,
|
||||
) *ExportController {
|
||||
return &ExportController{state, tableService, jobsController, columns, pasteboardProvider}
|
||||
}
|
||||
|
||||
func (c *ExportController) ExportCSV(filename string, opts ExportOptions) tea.Msg {
|
||||
|
|
@ -79,6 +92,54 @@ func (c *ExportController) ExportCSV(filename string, opts ExportOptions) tea.Ms
|
|||
}).Submit()
|
||||
}
|
||||
|
||||
func (c *ExportController) ExportCSVToClipboard() tea.Msg {
|
||||
var bts bytes.Buffer
|
||||
|
||||
resultSet := c.state.ResultSet()
|
||||
if resultSet == nil {
|
||||
return errors.New("no result set")
|
||||
}
|
||||
|
||||
if err := c.exportCSV(&bts, c.columns.Columns().VisibleColumns(), resultSet); err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
|
||||
if err := c.pasteboardProvider.WriteText(bts.Bytes()); err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: this really needs to be a service!
|
||||
func (c *ExportController) ExportToWriter(w io.Writer, resultSet *models.ResultSet) error {
|
||||
return c.exportCSV(w, columns.NewColumnsFromResultSet(resultSet).Columns, resultSet)
|
||||
}
|
||||
|
||||
func (c *ExportController) exportCSV(w io.Writer, cols []columns.Column, resultSet *models.ResultSet) error {
|
||||
cw := csv.NewWriter(w)
|
||||
defer cw.Flush()
|
||||
|
||||
colNames := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
colNames[i] = c.Name
|
||||
}
|
||||
if err := cw.Write(colNames); err != nil {
|
||||
return errors.Wrap(err, "cannot export to clipboard")
|
||||
}
|
||||
|
||||
row := make([]string, len(cols))
|
||||
for _, item := range resultSet.Items() {
|
||||
for i, col := range cols {
|
||||
row[i], _ = attrutils.AttributeToString(col.Evaluator.EvaluateForItem(item))
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return errors.Wrap(err, "cannot export to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ExportOptions struct {
|
||||
// AllResults returns all results from the table
|
||||
AllResults bool
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
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"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScriptController_RunScript(t *testing.T) {
|
||||
|
|
@ -53,7 +54,7 @@ func TestScriptController_RunScript(t *testing.T) {
|
|||
srv := newService(t, serviceConfig{
|
||||
tableName: "alpha-table",
|
||||
scriptFS: testScriptFile(t, "test.tm", `
|
||||
rs := session.query('pk="abc"').unwrap()
|
||||
rs := session.query('pk="abc"')
|
||||
ui.print(rs.length)
|
||||
`),
|
||||
})
|
||||
|
|
@ -72,7 +73,7 @@ func TestScriptController_RunScript(t *testing.T) {
|
|||
srv := newService(t, serviceConfig{
|
||||
tableName: "alpha-table",
|
||||
scriptFS: testScriptFile(t, "test.tm", `
|
||||
rs := session.query('pk!="abc"', { table: "count-to-30" }).unwrap()
|
||||
rs := session.query('pk!="abc"', { table: "count-to-30" })
|
||||
ui.print(rs.length)
|
||||
`),
|
||||
})
|
||||
|
|
@ -93,7 +94,7 @@ func TestScriptController_RunScript(t *testing.T) {
|
|||
srv := newService(t, serviceConfig{
|
||||
tableName: "alpha-table",
|
||||
scriptFS: testScriptFile(t, "test.tm", `
|
||||
rs := session.query('pk="abc"').unwrap()
|
||||
rs := session.query('pk="abc"')
|
||||
session.set_result_set(rs)
|
||||
`),
|
||||
})
|
||||
|
|
@ -112,7 +113,7 @@ func TestScriptController_RunScript(t *testing.T) {
|
|||
srv := newService(t, serviceConfig{
|
||||
tableName: "alpha-table",
|
||||
scriptFS: testScriptFile(t, "test.tm", `
|
||||
rs := session.query('pk="abc"').unwrap()
|
||||
rs := session.query('pk="abc"')
|
||||
rs[0].set_attr("pk", "131")
|
||||
session.set_result_set(rs)
|
||||
`),
|
||||
|
|
@ -135,22 +136,35 @@ func TestScriptController_RunScript(t *testing.T) {
|
|||
|
||||
func TestScriptController_LookupCommand(t *testing.T) {
|
||||
t.Run("should schedule the script on a separate go-routine", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{
|
||||
tableName: "alpha-table",
|
||||
scriptFS: testScriptFile(t, "test.tm", `
|
||||
ext.command("mycommand", func(name) {
|
||||
ui.print("Hello, ", name)
|
||||
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(`mycommand "test name"`))
|
||||
invokeCommand(t, srv.scriptController.LoadScript("test.tm"))
|
||||
invokeCommand(t, srv.commandController.Execute(scenario.command))
|
||||
|
||||
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
|
||||
srv.msgSender.waitForAtLeastOneMessages(t, 5*time.Second)
|
||||
|
||||
assert.Len(t, srv.msgSender.msgs, 1)
|
||||
assert.Equal(t, events.StatusMsg("Hello, test name"), srv.msgSender.msgs[0])
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
|
|
|
|||
|
|
@ -4,22 +4,24 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"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/attrcodec"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/attrutils"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/serialisable"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services"
|
||||
"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/viewsnapshot"
|
||||
bus "github.com/lmika/events"
|
||||
"github.com/pkg/errors"
|
||||
"golang.design/x/clipboard"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type resultSetUpdateOp int
|
||||
|
|
@ -57,11 +59,11 @@ type TableReadController struct {
|
|||
eventBus *bus.Bus
|
||||
tableName string
|
||||
loadFromLastView bool
|
||||
pasteboardProvider services.PasteboardProvider
|
||||
|
||||
// state
|
||||
mutex *sync.Mutex
|
||||
state *State
|
||||
clipboardInit bool
|
||||
mutex *sync.Mutex
|
||||
state *State
|
||||
}
|
||||
|
||||
func NewTableReadController(
|
||||
|
|
@ -72,6 +74,7 @@ func NewTableReadController(
|
|||
jobController *JobsController,
|
||||
inputHistoryService *inputhistory.Service,
|
||||
eventBus *bus.Bus,
|
||||
pasteboardProvider services.PasteboardProvider,
|
||||
tableName string,
|
||||
) *TableReadController {
|
||||
return &TableReadController{
|
||||
|
|
@ -83,6 +86,7 @@ func NewTableReadController(
|
|||
inputHistoryService: inputHistoryService,
|
||||
eventBus: eventBus,
|
||||
tableName: tableName,
|
||||
pasteboardProvider: pasteboardProvider,
|
||||
mutex: new(sync.Mutex),
|
||||
}
|
||||
}
|
||||
|
|
@ -276,13 +280,35 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet,
|
|||
return c.state.buildNewResultSetMessage("")
|
||||
}
|
||||
|
||||
func (c *TableReadController) Mark(op MarkOp) tea.Msg {
|
||||
c.state.withResultSet(func(resultSet *models.ResultSet) {
|
||||
for i := range resultSet.Items() {
|
||||
func (c *TableReadController) Mark(op MarkOp, where string) tea.Msg {
|
||||
var (
|
||||
whereExpr *queryexpr.QueryExpr
|
||||
err error
|
||||
)
|
||||
|
||||
if where != "" {
|
||||
whereExpr, err = queryexpr.Parse(where)
|
||||
if err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.state.withResultSetReturningError(func(resultSet *models.ResultSet) error {
|
||||
for i, item := range resultSet.Items() {
|
||||
if resultSet.Hidden(i) {
|
||||
continue
|
||||
}
|
||||
|
||||
if whereExpr != nil {
|
||||
res, err := whereExpr.EvalItem(item)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "item %d", i)
|
||||
}
|
||||
if !attrutils.Truthy(res) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case MarkOpMark:
|
||||
resultSet.SetMark(i, true)
|
||||
|
|
@ -292,7 +318,10 @@ func (c *TableReadController) Mark(op MarkOp) tea.Msg {
|
|||
resultSet.SetMark(i, !resultSet.Marked(i))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
return ResultSetUpdated{}
|
||||
}
|
||||
|
||||
|
|
@ -446,12 +475,8 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi
|
|||
}
|
||||
|
||||
func (c *TableReadController) CopyItemToClipboard(idx int) tea.Msg {
|
||||
if err := c.initClipboard(); err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
|
||||
itemCount := 0
|
||||
c.state.withResultSet(func(resultSet *models.ResultSet) {
|
||||
if err := c.state.withResultSetReturningError(func(resultSet *models.ResultSet) error {
|
||||
sb := new(strings.Builder)
|
||||
_ = applyToMarkedItems(resultSet, idx, func(idx int, item models.Item) error {
|
||||
if sb.Len() > 0 {
|
||||
|
|
@ -461,23 +486,14 @@ func (c *TableReadController) CopyItemToClipboard(idx int) tea.Msg {
|
|||
itemCount += 1
|
||||
return nil
|
||||
})
|
||||
clipboard.Write(clipboard.FmtText, []byte(sb.String()))
|
||||
})
|
||||
|
||||
if err := c.pasteboardProvider.WriteText([]byte(sb.String())); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
|
||||
return events.StatusMsg(applyToN("", itemCount, "item", "items", " copied to clipboard"))
|
||||
}
|
||||
|
||||
func (c *TableReadController) initClipboard() error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
if c.clipboardInit {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := clipboard.Init(); err != nil {
|
||||
return errors.Wrap(err, "unable to enable clipboard")
|
||||
}
|
||||
c.clipboardInit = true
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -458,6 +458,44 @@ func (twc *TableWriteController) assertReadWrite() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (twc *TableWriteController) CloneItem(idx int) tea.Msg {
|
||||
if err := twc.assertReadWrite(); err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
|
||||
// Work out which keys we need to prompt for
|
||||
rs := twc.state.ResultSet()
|
||||
|
||||
keyPrompts := &promptSequence{
|
||||
prompts: []string{rs.TableInfo.Keys.PartitionKey + ": "},
|
||||
}
|
||||
if rs.TableInfo.Keys.SortKey != "" {
|
||||
keyPrompts.prompts = append(keyPrompts.prompts, rs.TableInfo.Keys.SortKey+": ")
|
||||
}
|
||||
keyPrompts.onAllDone = func(values []string) tea.Msg {
|
||||
twc.state.withResultSet(func(set *models.ResultSet) {
|
||||
applyToMarkedItems(set, idx, func(idx int, item models.Item) error {
|
||||
// TODO: should be a deep clone
|
||||
clonedItem := item.Clone()
|
||||
|
||||
clonedItem[rs.TableInfo.Keys.PartitionKey] = &types.AttributeValueMemberS{Value: values[0]}
|
||||
if len(values) == 2 {
|
||||
clonedItem[rs.TableInfo.Keys.SortKey] = &types.AttributeValueMemberS{Value: values[1]}
|
||||
}
|
||||
|
||||
set.AddNewItem(clonedItem, models.ItemAttribute{
|
||||
New: true,
|
||||
Dirty: true,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return twc.state.buildNewResultSetMessage("New item cloned")
|
||||
}
|
||||
|
||||
return keyPrompts.next()
|
||||
}
|
||||
|
||||
func applyToN(prefix string, n int, singular, plural, suffix string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%v%v %v%v", prefix, n, singular, suffix)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
|
||||
"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"
|
||||
|
|
@ -617,11 +618,21 @@ func newService(t *testing.T, cfg serviceConfig) *services {
|
|||
|
||||
state := controllers.NewState()
|
||||
jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true)
|
||||
readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, jobsController, inputHistoryService, eventBus, cfg.tableName)
|
||||
readController := controllers.NewTableReadController(
|
||||
state,
|
||||
service,
|
||||
workspaceService,
|
||||
itemRendererService,
|
||||
jobsController,
|
||||
inputHistoryService,
|
||||
eventBus,
|
||||
pasteboardprovider.NilProvider{},
|
||||
cfg.tableName,
|
||||
)
|
||||
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
|
||||
settingsController := controllers.NewSettingsController(settingStore, eventBus)
|
||||
columnsController := controllers.NewColumnsController(eventBus)
|
||||
exportController := controllers.NewExportController(state, service, jobsController, columnsController)
|
||||
exportController := controllers.NewExportController(state, service, jobsController, columnsController, pasteboardprovider.NilProvider{})
|
||||
scriptController := controllers.NewScriptController(scriptService, readController, settingsController, eventBus)
|
||||
|
||||
commandController := commandctrl.NewCommandController(inputHistoryService)
|
||||
|
|
|
|||
32
internal/dynamo-browse/models/attrutils/truthy.go
Normal file
32
internal/dynamo-browse/models/attrutils/truthy.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package attrutils
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
)
|
||||
|
||||
func Truthy(x types.AttributeValue) bool {
|
||||
switch xVal := x.(type) {
|
||||
case *types.AttributeValueMemberS:
|
||||
return len(xVal.Value) > 0
|
||||
case *types.AttributeValueMemberN:
|
||||
return len(xVal.Value) > 0 && xVal.Value != "0"
|
||||
case *types.AttributeValueMemberBOOL:
|
||||
return xVal.Value
|
||||
case *types.AttributeValueMemberB:
|
||||
return len(xVal.Value) > 0
|
||||
case *types.AttributeValueMemberNULL:
|
||||
return !xVal.Value
|
||||
case *types.AttributeValueMemberL:
|
||||
return len(xVal.Value) > 0
|
||||
case *types.AttributeValueMemberM:
|
||||
return len(xVal.Value) > 0
|
||||
case *types.AttributeValueMemberBS:
|
||||
return len(xVal.Value) > 0
|
||||
case *types.AttributeValueMemberNS:
|
||||
return len(xVal.Value) > 0
|
||||
case *types.AttributeValueMemberSS:
|
||||
return len(xVal.Value) > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ type Item map[string]types.AttributeValue
|
|||
func (i Item) Clone() Item {
|
||||
newItem := Item{}
|
||||
|
||||
// TODO: should be a deep clone?
|
||||
// TODO: should be a deep clone? YES!!
|
||||
for k, v := range i {
|
||||
newItem[k] = v
|
||||
}
|
||||
|
|
@ -33,6 +33,14 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue {
|
|||
return itemKey
|
||||
}
|
||||
|
||||
func (i Item) PKSK(info *TableInfo) (pk types.AttributeValue, sk types.AttributeValue) {
|
||||
pk = i[info.Keys.PartitionKey]
|
||||
if info.Keys.SortKey != "" {
|
||||
sk = i[info.Keys.SortKey]
|
||||
}
|
||||
return pk, sk
|
||||
}
|
||||
|
||||
func (i Item) AttributeValueAsString(key string) (string, bool) {
|
||||
return attrutils.AttributeToString(i[key])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ package models
|
|||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ResultSet struct {
|
||||
// Query information
|
||||
TableInfo *TableInfo
|
||||
Query Queryable
|
||||
Created time.Time
|
||||
ExclusiveStartKey map[string]types.AttributeValue
|
||||
|
||||
// Result information
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package queryexpr
|
|||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
|
|
@ -50,6 +51,42 @@ var nativeFuncs = map[string]nativeFunc{
|
|||
return listExprValue(xs), nil
|
||||
},
|
||||
|
||||
"marked": func(ctx context.Context, args []exprValue) (exprValue, error) {
|
||||
if len(args) != 1 {
|
||||
return nil, InvalidArgumentNumberError{Name: "marked", Expected: 1, Actual: len(args)}
|
||||
}
|
||||
|
||||
fieldName, ok := args[0].(stringableExprValue)
|
||||
if !ok {
|
||||
return nil, InvalidArgumentTypeError{Name: "marked", ArgIndex: 0, Expected: "S"}
|
||||
}
|
||||
|
||||
rs := currentResultSetFromContext(ctx)
|
||||
if rs == nil {
|
||||
return listExprValue{}, nil
|
||||
}
|
||||
|
||||
var items = []exprValue{}
|
||||
for i, itm := range rs.Items() {
|
||||
if !rs.Marked(i) {
|
||||
continue
|
||||
}
|
||||
|
||||
attr, hasAttr := itm[fieldName.asString()]
|
||||
if !hasAttr {
|
||||
continue
|
||||
}
|
||||
|
||||
exprAttrValue, err := newExprValueFromAttributeValue(attr)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "marked(): item %d, attr %v", i, fieldName.asString())
|
||||
}
|
||||
|
||||
items = append(items, exprAttrValue)
|
||||
}
|
||||
return listExprValue(items), nil
|
||||
},
|
||||
|
||||
"_x_now": func(ctx context.Context, args []exprValue) (exprValue, error) {
|
||||
now := timeSourceFromContext(ctx).now().Unix()
|
||||
return int64ExprValue(now), nil
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package queryexpr
|
|||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
|
||||
)
|
||||
|
||||
type timeSource interface {
|
||||
|
|
@ -25,3 +27,14 @@ func timeSourceFromContext(ctx context.Context) timeSource {
|
|||
}
|
||||
return defaultTimeSource{}
|
||||
}
|
||||
|
||||
type currentResultSetContextKeyType struct{}
|
||||
|
||||
var currentResultSetContextKey = currentResultSetContextKeyType{}
|
||||
|
||||
func currentResultSetFromContext(ctx context.Context) *models.ResultSet {
|
||||
if crs, ok := ctx.Value(currentResultSetContextKey).(*models.ResultSet); ok {
|
||||
return crs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func (dt *astRef) unqualifiedName() (string, bool) {
|
|||
func (dt *astRef) evalItem(ctx *evalContext, item models.Item) (exprValue, error) {
|
||||
res, hasV := item[dt.Name]
|
||||
if !hasV {
|
||||
return nil, nil
|
||||
return undefinedExprValue{}, nil
|
||||
}
|
||||
|
||||
return newExprValueFromAttributeValue(res)
|
||||
|
|
|
|||
|
|
@ -73,8 +73,6 @@ func (a *astEqualityOp) evalItem(ctx *evalContext, item models.Item) (exprValue,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: use expr values here
|
||||
|
||||
switch a.Op {
|
||||
case "=":
|
||||
cmp, isComparable := attrutils.CompareScalarAttributes(left.asAttributeValue(), right.asAttributeValue())
|
||||
|
|
@ -96,7 +94,7 @@ func (a *astEqualityOp) evalItem(ctx *evalContext, item models.Item) (exprValue,
|
|||
|
||||
leftAsStr, canBeString := left.(stringableExprValue)
|
||||
if !canBeString {
|
||||
return nil, ValueNotConvertableToString{Val: leftAsStr.asAttributeValue()}
|
||||
return nil, ValueNotConvertableToString{Val: left.asAttributeValue()}
|
||||
}
|
||||
return boolExprValue(strings.HasPrefix(leftAsStr.asString(), strValue.asString())), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ type ValueNotConvertableToString struct {
|
|||
|
||||
func (n ValueNotConvertableToString) Error() string {
|
||||
render := itemrender.ToRenderer(n.Val)
|
||||
if render == nil {
|
||||
return "nil value is not convertable to string"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("values '%v', type %v, is not convertable to string", render.StringValue(), render.TypeName())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package queryexpr
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
|
||||
"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/attrcodec"
|
||||
|
|
@ -10,15 +13,14 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
)
|
||||
|
||||
type QueryExpr struct {
|
||||
ast *astExpr
|
||||
index string
|
||||
names map[string]string
|
||||
values map[string]types.AttributeValue
|
||||
ast *astExpr
|
||||
index string
|
||||
names map[string]string
|
||||
values map[string]types.AttributeValue
|
||||
currentResultSet *models.ResultSet
|
||||
|
||||
// tests fields only
|
||||
timeSource timeSource
|
||||
|
|
@ -141,10 +143,11 @@ func (md *QueryExpr) HashCode() uint64 {
|
|||
|
||||
func (md *QueryExpr) WithNameParams(value map[string]string) *QueryExpr {
|
||||
return &QueryExpr{
|
||||
ast: md.ast,
|
||||
index: md.index,
|
||||
names: value,
|
||||
values: md.values,
|
||||
ast: md.ast,
|
||||
index: md.index,
|
||||
names: value,
|
||||
values: md.values,
|
||||
currentResultSet: md.currentResultSet,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,19 +169,31 @@ func (md *QueryExpr) ValueParamOrNil(name string) types.AttributeValue {
|
|||
|
||||
func (md *QueryExpr) WithValueParams(value map[string]types.AttributeValue) *QueryExpr {
|
||||
return &QueryExpr{
|
||||
ast: md.ast,
|
||||
index: md.index,
|
||||
names: md.names,
|
||||
values: value,
|
||||
ast: md.ast,
|
||||
index: md.index,
|
||||
names: md.names,
|
||||
values: value,
|
||||
currentResultSet: md.currentResultSet,
|
||||
}
|
||||
}
|
||||
|
||||
func (md *QueryExpr) WithIndex(index string) *QueryExpr {
|
||||
return &QueryExpr{
|
||||
ast: md.ast,
|
||||
index: index,
|
||||
names: md.names,
|
||||
values: md.values,
|
||||
ast: md.ast,
|
||||
index: index,
|
||||
names: md.names,
|
||||
values: md.values,
|
||||
currentResultSet: md.currentResultSet,
|
||||
}
|
||||
}
|
||||
|
||||
func (md *QueryExpr) WithCurrentResultSet(currentResultSet *models.ResultSet) *QueryExpr {
|
||||
return &QueryExpr{
|
||||
ast: md.ast,
|
||||
index: md.index,
|
||||
names: md.names,
|
||||
values: md.values,
|
||||
currentResultSet: currentResultSet,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,6 +232,7 @@ func (md *QueryExpr) evalContext() *evalContext {
|
|||
return &evalContext{
|
||||
namePlaceholders: md.names,
|
||||
valuePlaceholders: md.values,
|
||||
ctxResultSet: md.currentResultSet,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +284,7 @@ type evalContext struct {
|
|||
valuePlaceholders map[string]types.AttributeValue
|
||||
valueLookup func(string) (types.AttributeValue, bool)
|
||||
timeSource timeSource
|
||||
ctxResultSet *models.ResultSet
|
||||
}
|
||||
|
||||
func (ec *evalContext) lookupName(name string) (string, bool) {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ package queryexpr_test
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models/queryexpr"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -603,19 +604,40 @@ func TestQueryExpr_EvalItem(t *testing.T) {
|
|||
t.Run("functions", func(t *testing.T) {
|
||||
timeNow := time.Now()
|
||||
|
||||
contextResultSet := models.ResultSet{}
|
||||
contextResultSet.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1"}, "num": &types.AttributeValueMemberN{Value: "1"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "2"}, "num": &types.AttributeValueMemberN{Value: "2"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "3"}, "num": &types.AttributeValueMemberN{Value: "3"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "4"}, "num": &types.AttributeValueMemberN{Value: "4"}},
|
||||
})
|
||||
contextResultSet.SetMark(0, true)
|
||||
contextResultSet.SetMark(1, true)
|
||||
|
||||
scenarios := []struct {
|
||||
expr string
|
||||
expected types.AttributeValue
|
||||
}{
|
||||
// _x_now() -- unreleased version of now
|
||||
{expr: `_x_now()`, expected: &types.AttributeValueMemberN{Value: fmt.Sprint(timeNow.Unix())}},
|
||||
|
||||
// Marked
|
||||
{expr: `marked("num")`, expected: &types.AttributeValueMemberL{Value: []types.AttributeValue{
|
||||
&types.AttributeValueMemberN{Value: "1"},
|
||||
&types.AttributeValueMemberN{Value: "2"},
|
||||
}}},
|
||||
{expr: `one in marked("num")`, expected: &types.AttributeValueMemberBOOL{Value: true}},
|
||||
{expr: `three in marked("num")`, expected: &types.AttributeValueMemberBOOL{Value: false}},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.expr, func(t *testing.T) {
|
||||
modExpr, err := queryexpr.Parse(scenario.expr)
|
||||
assert.NoError(t, err)
|
||||
|
||||
res, err := modExpr.WithTestTimeSource(timeNow).EvalItem(item)
|
||||
res, err := modExpr.
|
||||
WithTestTimeSource(timeNow).
|
||||
WithCurrentResultSet(&contextResultSet).
|
||||
EvalItem(item)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, scenario.expected, res)
|
||||
|
|
@ -646,6 +668,10 @@ func TestQueryExpr_EvalItem(t *testing.T) {
|
|||
}{
|
||||
{expr: `alpha.bravo`, expectedError: queryexpr.ValueNotAMapError([]string{"alpha", "bravo"})},
|
||||
{expr: `charlie.tree.bla`, expectedError: queryexpr.ValueNotAMapError([]string{"charlie", "tree", "bla"})},
|
||||
|
||||
{expr: `missing="no"`, expectedError: queryexpr.ValuesNotComparable{Right: &types.AttributeValueMemberS{Value: "no"}}},
|
||||
{expr: `missing!="no"`, expectedError: queryexpr.ValuesNotComparable{Right: &types.AttributeValueMemberS{Value: "no"}}},
|
||||
{expr: `missing^="no"`, expectedError: queryexpr.ValueNotConvertableToString{nil}},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ package queryexpr
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
|
||||
"github.com/lmika/dynamo-browse/internal/common/sliceutils"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *astFunctionCall) evalToIR(ctx *evalContext, info *models.TableInfo) (irAtom, error) {
|
||||
|
|
@ -88,6 +89,7 @@ func (a *astFunctionCall) evalItem(ctx *evalContext, item models.Item) (exprValu
|
|||
}
|
||||
|
||||
cCtx := context.WithValue(context.Background(), timeSourceContextKey, ctx.timeSource)
|
||||
cCtx = context.WithValue(cCtx, currentResultSetContextKey, ctx.ctxResultSet)
|
||||
return fn(cCtx, args)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ func (a *QueryExpr) WithTestTimeSource(timeNow time.Time) *QueryExpr {
|
|||
a.timeSource = testTimeSource(timeNow)
|
||||
return a
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ func (a *astIsOp) evalItem(ctx *evalContext, item models.Item) (exprValue, error
|
|||
|
||||
var resultOfIs bool
|
||||
if typeInfo.isAny {
|
||||
resultOfIs = ref != nil
|
||||
resultOfIs = ref != undefinedExprValue{}
|
||||
} else {
|
||||
refType := reflect.TypeOf(ref)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,20 @@ func newExprValueFromAttributeValue(ev types.AttributeValue) (exprValue, error)
|
|||
return nil, errors.New("cannot convert to expr value")
|
||||
}
|
||||
|
||||
type undefinedExprValue struct{}
|
||||
|
||||
func (b undefinedExprValue) asGoValue() any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b undefinedExprValue) asAttributeValue() types.AttributeValue {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s undefinedExprValue) typeName() string {
|
||||
return "UNDEFINED"
|
||||
}
|
||||
|
||||
type stringExprValue string
|
||||
|
||||
func (s stringExprValue) asGoValue() any {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package pasteboardprovider
|
||||
|
||||
type NilProvider struct{}
|
||||
|
||||
func (NilProvider) ReadText() (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (n NilProvider) WriteText(bts []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package pasteboardprovider
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"golang.design/x/clipboard"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
mutex *sync.Mutex
|
||||
clipboardInit bool
|
||||
}
|
||||
|
||||
func New() *Provider {
|
||||
return &Provider{
|
||||
mutex: new(sync.Mutex),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Provider) initClipboard() error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
if c.clipboardInit {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := clipboard.Init(); err != nil {
|
||||
return errors.Wrap(err, "unable to enable clipboard")
|
||||
}
|
||||
c.clipboardInit = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Provider) WriteText(bts []byte) error {
|
||||
if err := c.initClipboard(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clipboard.Write(clipboard.FmtText, bts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Provider) ReadText() (string, bool) {
|
||||
if err := c.initClipboard(); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
content := clipboard.Read(clipboard.FmtText)
|
||||
if content == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return string(content), true
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ func (c *SettingStore) SetDefaultLimit(limit int) error {
|
|||
|
||||
func (c *SettingStore) getStringValue(key string, def string) (string, error) {
|
||||
var val string
|
||||
if err := c.ws.Get(settingBucket, keyTableReadOnly, &val); err != nil {
|
||||
if err := c.ws.Get(settingBucket, key, &val); err != nil {
|
||||
if errors.Is(err, storm.ErrNotFound) {
|
||||
return def, nil
|
||||
}
|
||||
|
|
|
|||
6
internal/dynamo-browse/services/pasteboardprovider.go
Normal file
6
internal/dynamo-browse/services/pasteboardprovider.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package services
|
||||
|
||||
type PasteboardProvider interface {
|
||||
ReadText() (string, bool)
|
||||
WriteText(bts []byte) error
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@ package scriptmanager
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"fmt"
|
||||
"github.com/risor-io/risor/object"
|
||||
"log"
|
||||
)
|
||||
|
||||
|
|
@ -53,3 +54,19 @@ func printfBuiltin(ctx context.Context, args ...object.Object) object.Object {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ package scriptmanager
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/risor-io/risor/object"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
|
|
@ -18,22 +16,17 @@ type extModule struct {
|
|||
scriptPlugin *ScriptPlugin
|
||||
}
|
||||
|
||||
func (m *extModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("ext", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("command", m.command, mod),
|
||||
object.NewBuiltin("key_binding", m.keyBinding, mod),
|
||||
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),
|
||||
})
|
||||
|
||||
scp.Declare("ext", mod, true)
|
||||
}
|
||||
|
||||
func (m *extModule) command(ctx context.Context, args ...object.Object) object.Object {
|
||||
thisEnv := scriptEnvFromCtx(ctx)
|
||||
|
||||
if err := arg.Require("ext.command", 2, args); err != nil {
|
||||
if err := require("ext.command", 2, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -62,8 +55,10 @@ func (m *extModule) command(ctx context.Context, args ...object.Object) object.O
|
|||
newEnv.options = m.scriptPlugin.scriptService.options
|
||||
ctx = ctxWithScriptEnv(ctx, newEnv)
|
||||
|
||||
res := callFn(ctx, fnRes.Scope(), fnRes, objArgs)
|
||||
if object.IsError(res) {
|
||||
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())
|
||||
}
|
||||
|
|
@ -80,7 +75,7 @@ func (m *extModule) command(ctx context.Context, args ...object.Object) object.O
|
|||
func (m *extModule) keyBinding(ctx context.Context, args ...object.Object) object.Object {
|
||||
thisEnv := scriptEnvFromCtx(ctx)
|
||||
|
||||
if err := arg.Require("ext.key_binding", 3, args); err != nil {
|
||||
if err := require("ext.key_binding", 3, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -122,8 +117,10 @@ func (m *extModule) keyBinding(ctx context.Context, args ...object.Object) objec
|
|||
newEnv.options = m.scriptPlugin.scriptService.options
|
||||
ctx = ctxWithScriptEnv(ctx, newEnv)
|
||||
|
||||
res := callFn(ctx, fnRes.Scope(), fnRes, objArgs)
|
||||
if object.IsError(res) {
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ package scriptmanager
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"github.com/risor-io/risor/object"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
|
@ -13,7 +11,7 @@ type osModule struct {
|
|||
}
|
||||
|
||||
func (om *osModule) exec(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("os.exec", 1, args); err != nil {
|
||||
if err := require("os.exec", 1, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -24,20 +22,20 @@ func (om *osModule) exec(ctx context.Context, args ...object.Object) object.Obje
|
|||
|
||||
opts := scriptEnvFromCtx(ctx).options
|
||||
if !opts.Permissions.AllowShellCommands {
|
||||
return object.NewErrResult(object.Errorf("permission error: no permission to shell out"))
|
||||
return object.Errorf("permission error: no permission to shell out")
|
||||
}
|
||||
|
||||
cmd := exec.Command(opts.OSExecShell, "-c", cmdExec)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return object.NewErrResult(object.NewError(err))
|
||||
return object.NewError(err)
|
||||
}
|
||||
|
||||
return object.NewOkResult(object.NewString(string(out)))
|
||||
return object.NewString(string(out))
|
||||
}
|
||||
|
||||
func (om *osModule) env(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("os.env", 1, args); err != nil {
|
||||
if err := require("os.env", 1, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -58,14 +56,9 @@ func (om *osModule) env(ctx context.Context, args ...object.Object) object.Objec
|
|||
return object.NewString(envVal)
|
||||
}
|
||||
|
||||
func (om *osModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("os", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("exec", om.exec, mod),
|
||||
object.NewBuiltin("env", om.env, mod),
|
||||
func (om *osModule) register() *object.Module {
|
||||
return object.NewBuiltinsModule("os", map[string]object.Object{
|
||||
"exec": object.NewBuiltin("exec", om.exec),
|
||||
"env": object.NewBuiltin("env", om.env),
|
||||
})
|
||||
|
||||
scp.Declare("os", mod, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,13 +68,11 @@ func TestOSModule_Env(t *testing.T) {
|
|||
func TestOSModule_Exec(t *testing.T) {
|
||||
t.Run("should run command and return stdout", func(t *testing.T) {
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "false")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "hello world\n")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := os.exec('echo "hello world"')
|
||||
ui.print(res.is_err())
|
||||
ui.print(res.unwrap())
|
||||
ui.print(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -97,11 +95,11 @@ func TestOSModule_Exec(t *testing.T) {
|
|||
|
||||
t.Run("should refuse to execute command if do not have permissions", func(t *testing.T) {
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "true")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "failed")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := os.exec('echo "hello world"')
|
||||
ui.print(res.is_err())
|
||||
res := try(func() { return os.exec('echo "hello world"') }, "failed")
|
||||
ui.print(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -125,14 +123,13 @@ func TestOSModule_Exec(t *testing.T) {
|
|||
t.Run("should be able to change permissions which will affect plugins", func(t *testing.T) {
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Loaded the plugin\n")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "true")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
ext.command("mycommand", func() {
|
||||
ui.print(os.exec('echo "this cannot run"').is_err())
|
||||
ui.print(os.exec('echo "this cannot run"'))
|
||||
})
|
||||
|
||||
ui.print(os.exec('echo "Loaded the plugin"').unwrap())
|
||||
ui.print(os.exec('echo "Loaded the plugin"'))
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -159,7 +156,7 @@ func TestOSModule_Exec(t *testing.T) {
|
|||
|
||||
errChan := make(chan error)
|
||||
assert.NoError(t, srv.LookupCommand("mycommand").Invoke(ctx, []string{}, errChan))
|
||||
assert.NoError(t, waitForErr(t, errChan))
|
||||
assert.Error(t, waitForErr(t, errChan))
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/risor-io/risor/object"
|
||||
)
|
||||
|
||||
type sessionModule struct {
|
||||
|
|
@ -77,13 +75,13 @@ func (um *sessionModule) query(ctx context.Context, args ...object.Object) objec
|
|||
resp, err := um.sessionService.Query(ctx, expr, options)
|
||||
|
||||
if err != nil {
|
||||
return object.NewErrResult(object.NewError(err))
|
||||
return object.NewError(err)
|
||||
}
|
||||
return object.NewOkResult(&resultSetProxy{resultSet: resp})
|
||||
return &resultSetProxy{resultSet: resp}
|
||||
}
|
||||
|
||||
func (um *sessionModule) resultSet(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("session.result_set", 0, args); err != nil {
|
||||
if err := require("session.result_set", 0, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +93,7 @@ func (um *sessionModule) resultSet(ctx context.Context, args ...object.Object) o
|
|||
}
|
||||
|
||||
func (um *sessionModule) selectedItem(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("session.result_set", 0, args); err != nil {
|
||||
if err := require("session.result_set", 0, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +108,7 @@ func (um *sessionModule) selectedItem(ctx context.Context, args ...object.Object
|
|||
}
|
||||
|
||||
func (um *sessionModule) setResultSet(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("session.set_result_set", 1, args); err != nil {
|
||||
if err := require("session.set_result_set", 1, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +122,7 @@ func (um *sessionModule) setResultSet(ctx context.Context, args ...object.Object
|
|||
}
|
||||
|
||||
func (um *sessionModule) currentTable(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("session.current_table", 0, args); err != nil {
|
||||
if err := require("session.current_table", 0, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -136,17 +134,12 @@ func (um *sessionModule) currentTable(ctx context.Context, args ...object.Object
|
|||
return &tableProxy{table: rs.TableInfo}
|
||||
}
|
||||
|
||||
func (um *sessionModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("session", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("query", um.query, mod),
|
||||
object.NewBuiltin("current_table", um.currentTable, mod),
|
||||
object.NewBuiltin("result_set", um.resultSet, mod),
|
||||
object.NewBuiltin("selected_item", um.selectedItem, mod),
|
||||
object.NewBuiltin("set_result_set", um.setResultSet, mod),
|
||||
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),
|
||||
})
|
||||
|
||||
scp.Declare("session", mod, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ func TestModSession_Query(t *testing.T) {
|
|||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1].attr('size(pk)') = 4")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
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"))
|
||||
|
|
@ -128,13 +128,9 @@ func TestModSession_Query(t *testing.T) {
|
|||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(nil, errors.New("bang"))
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "true")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "err(\"bang\")")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr")
|
||||
ui.print(res.is_err())
|
||||
ui.print(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -145,7 +141,7 @@ func TestModSession_Query(t *testing.T) {
|
|||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
assert.Error(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
|
|
@ -165,7 +161,7 @@ func TestModSession_Query(t *testing.T) {
|
|||
res := session.query("some expr", {
|
||||
table: "some-table",
|
||||
})
|
||||
assert(!res.is_err())
|
||||
assert(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -201,7 +197,7 @@ func TestModSession_Query(t *testing.T) {
|
|||
res := session.query("some expr", {
|
||||
table: session.result_set().table,
|
||||
})
|
||||
assert(!res.is_err())
|
||||
assert(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -242,7 +238,7 @@ func TestModSession_Query(t *testing.T) {
|
|||
value: "world",
|
||||
},
|
||||
})
|
||||
assert(!res.is_err())
|
||||
assert(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -288,7 +284,7 @@ func TestModSession_Query(t *testing.T) {
|
|||
"nil": nil,
|
||||
},
|
||||
})
|
||||
assert(!res.is_err())
|
||||
assert(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -315,7 +311,6 @@ func TestModSession_Query(t *testing.T) {
|
|||
"bad": func() { },
|
||||
},
|
||||
})
|
||||
assert(res.is_err())
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
|
|
@ -411,7 +406,7 @@ func TestModSession_SetResultSet(t *testing.T) {
|
|||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
res := session.query("some expr")
|
||||
session.set_result_set(res)
|
||||
`)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ package scriptmanager
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"strings"
|
||||
|
||||
"github.com/risor-io/risor/object"
|
||||
)
|
||||
|
||||
type uiModule struct {
|
||||
|
|
@ -15,6 +14,10 @@ type uiModule struct {
|
|||
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())
|
||||
|
|
@ -28,7 +31,7 @@ func (um *uiModule) print(ctx context.Context, args ...object.Object) object.Obj
|
|||
}
|
||||
|
||||
func (um *uiModule) prompt(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("ui.prompt", 1, args); err != nil {
|
||||
if err := require("ui.prompt", 1, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -47,14 +50,9 @@ func (um *uiModule) prompt(ctx context.Context, args ...object.Object) object.Ob
|
|||
}
|
||||
}
|
||||
|
||||
func (um *uiModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("ui", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("print", um.print, mod),
|
||||
object.NewBuiltin("prompt", um.prompt, mod),
|
||||
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),
|
||||
})
|
||||
|
||||
scp.Declare("ui", mod, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package scriptmanager
|
|||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/risor-io/risor/limits"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
|
|
@ -50,5 +52,7 @@ func scriptEnvFromCtx(ctx context.Context) scriptEnv {
|
|||
}
|
||||
|
||||
func ctxWithScriptEnv(ctx context.Context, perms scriptEnv) context.Context {
|
||||
return context.WithValue(ctx, scriptEnvKey, perms)
|
||||
newCtx := context.WithValue(ctx, scriptEnvKey, perms)
|
||||
newCtx = limits.WithLimits(newCtx, limits.New())
|
||||
return newCtx
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,33 @@ package scriptmanager
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"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 (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
|
||||
}
|
||||
|
|
@ -95,17 +111,105 @@ func (r *resultSetProxy) GetAttr(name string) (object.Object, bool) {
|
|||
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,
|
||||
|
|
@ -154,7 +258,7 @@ func (i *itemProxy) GetAttr(name string) (object.Object, bool) {
|
|||
}
|
||||
|
||||
func (i *itemProxy) value(ctx context.Context, args ...object.Object) object.Object {
|
||||
if objErr := arg.Require("item.attr", 1, args); objErr != nil {
|
||||
if objErr := require("item.attr", 1, args); objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +284,7 @@ func (i *itemProxy) value(ctx context.Context, args ...object.Object) object.Obj
|
|||
}
|
||||
|
||||
func (i *itemProxy) setValue(ctx context.Context, args ...object.Object) object.Object {
|
||||
if objErr := arg.Require("item.set_attr", 2, args); objErr != nil {
|
||||
if objErr := require("item.set_attr", 2, args); objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +311,7 @@ func (i *itemProxy) setValue(ctx context.Context, args ...object.Object) object.
|
|||
}
|
||||
|
||||
func (i *itemProxy) deleteAttr(ctx context.Context, args ...object.Object) object.Object {
|
||||
if objErr := arg.Require("item.delete_attr", 1, args); objErr != nil {
|
||||
if objErr := require("item.delete_attr", 1, args); objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ 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"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResultSetProxy(t *testing.T) {
|
||||
|
|
@ -29,7 +30,7 @@ func TestResultSetProxy(t *testing.T) {
|
|||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
res := session.query("some expr")
|
||||
|
||||
// Test properties of the result set
|
||||
assert(res.table.name, "hello")
|
||||
|
|
@ -60,6 +61,123 @@ func TestResultSetProxy(t *testing.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{}
|
||||
|
|
@ -87,7 +205,7 @@ func TestResultSetProxy_GetAttr(t *testing.T) {
|
|||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
res := session.query("some expr")
|
||||
|
||||
assert(res[0].attr("pk") == "abc", "str attr")
|
||||
assert(res[0].attr("sk") == 123, "num attr")
|
||||
|
|
@ -164,7 +282,7 @@ func TestResultSetProxy_SetAttr(t *testing.T) {
|
|||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
res := session.query("some expr")
|
||||
|
||||
res[0].set_attr("pk", "bla-di-bla")
|
||||
res[0].set_attr("num", 123)
|
||||
|
|
@ -215,7 +333,7 @@ func TestResultSetProxy_DeleteAttr(t *testing.T) {
|
|||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
res := session.query("some expr")
|
||||
res[0].delete_attr("deleteMe")
|
||||
session.set_result_set(res)
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ package scriptmanager
|
|||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/exec"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/services/keybindings"
|
||||
"github.com/pkg/errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
|
|
@ -94,19 +94,14 @@ func (s *Service) startAdHocScript(ctx context.Context, filename string, errChan
|
|||
return
|
||||
}
|
||||
|
||||
scp := scope.New(scope.Opts{Parent: s.parentScope()})
|
||||
|
||||
ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename), options: s.options})
|
||||
|
||||
if _, err = exec.Execute(ctx, exec.Opts{
|
||||
Input: string(code),
|
||||
File: filename,
|
||||
Scope: scp,
|
||||
Builtins: []*object.Builtin{
|
||||
object.NewBuiltin("print", printBuiltin),
|
||||
object.NewBuiltin("printf", printfBuiltin),
|
||||
},
|
||||
}); err != nil {
|
||||
if _, err := risor.Eval(ctx, code,
|
||||
risor.WithGlobals(s.builtins()),
|
||||
// risor.WithDefaultBuiltins(),
|
||||
// risor.WithDefaultModules(),
|
||||
// risor.WithBuiltins(s.builtins()),
|
||||
); err != nil {
|
||||
errChan <- errors.Wrapf(err, "script %v", filename)
|
||||
return
|
||||
}
|
||||
|
|
@ -131,17 +126,17 @@ func (s *Service) loadScript(ctx context.Context, filename string, resChan chan
|
|||
scriptService: s,
|
||||
}
|
||||
|
||||
scp := scope.New(scope.Opts{Parent: s.parentScope()})
|
||||
|
||||
(&extModule{scriptPlugin: newPlugin}).register(scp)
|
||||
|
||||
ctx = ctxWithScriptEnv(ctx, scriptEnv{filename: filepath.Base(filename), options: s.options})
|
||||
|
||||
if _, err = exec.Execute(ctx, exec.Opts{
|
||||
Input: string(code),
|
||||
File: filename,
|
||||
Scope: scp,
|
||||
}); err != nil {
|
||||
if _, err := risor.Eval(ctx, code,
|
||||
// risor.WithDefaultBuiltins(),
|
||||
// risor.WithDefaultModules(),
|
||||
// risor.WithBuiltins(s.builtins()),
|
||||
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
|
||||
}
|
||||
|
|
@ -149,7 +144,7 @@ func (s *Service) loadScript(ctx context.Context, filename string, resChan chan
|
|||
resChan <- loadedScriptResult{scriptPlugin: newPlugin}
|
||||
}
|
||||
|
||||
func (s *Service) readScript(filename string, allowCwd bool) ([]byte, error) {
|
||||
func (s *Service) readScript(filename string, allowCwd bool) (string, error) {
|
||||
if allowCwd {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
fullScriptPath := filepath.Join(cwd, filename)
|
||||
|
|
@ -157,9 +152,9 @@ func (s *Service) readScript(filename string, allowCwd bool) ([]byte, error) {
|
|||
if stat, err := os.Stat(fullScriptPath); err == nil && !stat.IsDir() {
|
||||
code, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
return code, nil
|
||||
return string(code), nil
|
||||
}
|
||||
} else {
|
||||
log.Printf("warn: cannot get cwd for reading script %v: %v", filename, err)
|
||||
|
|
@ -169,9 +164,9 @@ func (s *Service) readScript(filename string, allowCwd bool) ([]byte, error) {
|
|||
if strings.HasPrefix(filename, string(filepath.Separator)) {
|
||||
code, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
return code, nil
|
||||
return string(code), nil
|
||||
}
|
||||
|
||||
for _, currFS := range s.lookupPaths {
|
||||
|
|
@ -181,7 +176,7 @@ func (s *Service) readScript(filename string, allowCwd bool) ([]byte, error) {
|
|||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
} else {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
} else if stat.IsDir() {
|
||||
continue
|
||||
|
|
@ -189,13 +184,13 @@ func (s *Service) readScript(filename string, allowCwd bool) ([]byte, error) {
|
|||
|
||||
code, err := fs.ReadFile(currFS, filename)
|
||||
if err == nil {
|
||||
return code, nil
|
||||
return string(code), nil
|
||||
} else {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, os.ErrNotExist
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
// LookupCommand looks up a command defined by a script.
|
||||
|
|
@ -252,10 +247,12 @@ func (s *Service) RebindKeyBinding(keyBinding string, newKey string) error {
|
|||
return keybindings.InvalidBindingError(keyBinding)
|
||||
}
|
||||
|
||||
func (s *Service) parentScope() *scope.Scope {
|
||||
scp := scope.New(scope.Opts{})
|
||||
(&uiModule{uiService: s.ifaces.UI}).register(scp)
|
||||
(&sessionModule{sessionService: s.ifaces.Session}).register(scp)
|
||||
(&osModule{}).register(scp)
|
||||
return scp
|
||||
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(),
|
||||
"os": (&osModule{}).register(),
|
||||
"print": object.NewBuiltin("print", printBuiltin),
|
||||
"printf": object.NewBuiltin("printf", printfBuiltin),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -16,6 +18,18 @@ 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"
|
||||
}
|
||||
|
|
@ -68,6 +82,18 @@ 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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package scriptmanager
|
|||
import (
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ func (s *Service) doScan(
|
|||
if err != nil && len(results) == 0 {
|
||||
return &models.ResultSet{
|
||||
TableInfo: tableInfo,
|
||||
Created: time.Now(),
|
||||
Query: expr,
|
||||
ExclusiveStartKey: exclusiveStartKey,
|
||||
LastEvaluatedKey: lastEvalKey,
|
||||
|
|
@ -89,6 +90,7 @@ func (s *Service) doScan(
|
|||
|
||||
resultSet := &models.ResultSet{
|
||||
TableInfo: tableInfo,
|
||||
Created: time.Now(),
|
||||
Query: expr,
|
||||
ExclusiveStartKey: exclusiveStartKey,
|
||||
LastEvaluatedKey: lastEvalKey,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ func Default() *KeyBindings {
|
|||
},
|
||||
View: &ViewKeyBindings{
|
||||
Mark: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "mark")),
|
||||
ToggleMarkedItems: key.NewBinding(key.WithKeys("M"), key.WithHelp("M", "toggle marged items")),
|
||||
CopyItemToClipboard: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy item to clipboard")),
|
||||
Rescan: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "rescan")),
|
||||
PromptForQuery: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "prompt for query")),
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ type TableKeyBinding struct {
|
|||
|
||||
type ViewKeyBindings struct {
|
||||
Mark key.Binding `keymap:"mark"`
|
||||
ToggleMarkedItems key.Binding `keymap:"toggle-marked-items"`
|
||||
CopyItemToClipboard key.Binding `keymap:"copy-item-to-clipboard"`
|
||||
CopyTableToClipboard key.Binding `keymap:"copy-table-to-clipboard"`
|
||||
Rescan key.Binding `keymap:"rescan"`
|
||||
PromptForQuery key.Binding `keymap:"prompt-for-query"`
|
||||
PromptForFilter key.Binding `keymap:"prompt-for-filter"`
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"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"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/colselector"
|
||||
|
|
@ -74,6 +75,7 @@ func NewModel(
|
|||
scriptController *controllers.ScriptController,
|
||||
eventBus *bus.Bus,
|
||||
keyBindingController *controllers.KeyBindingController,
|
||||
pasteboardProvider services.PasteboardProvider,
|
||||
defaultKeyMap *keybindings.KeyBindings,
|
||||
) Model {
|
||||
uiStyles := styles.DefaultStyles
|
||||
|
|
@ -84,7 +86,7 @@ func NewModel(
|
|||
|
||||
colSelector := colselector.New(mainView, defaultKeyMap, columnsController)
|
||||
itemEdit := dynamoitemedit.NewModel(colSelector)
|
||||
statusAndPrompt := statusandprompt.New(itemEdit, "", uiStyles.StatusAndPrompt)
|
||||
statusAndPrompt := statusandprompt.New(itemEdit, pasteboardProvider, "", uiStyles.StatusAndPrompt)
|
||||
dialogPrompt := dialogprompt.New(statusAndPrompt)
|
||||
tableSelect := tableselect.New(dialogPrompt, uiStyles)
|
||||
|
||||
|
|
@ -126,7 +128,12 @@ func NewModel(
|
|||
}
|
||||
}
|
||||
|
||||
return rc.Mark(markOp)
|
||||
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()
|
||||
|
|
@ -135,6 +142,9 @@ func NewModel(
|
|||
|
||||
// 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"))
|
||||
|
|
@ -263,10 +273,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
|
||||
return m, events.SetTeaMessage(m.tableWriteController.ToggleMark(idx))
|
||||
}
|
||||
case key.Matches(msg, m.keyMap.ToggleMarkedItems):
|
||||
return m, events.SetTeaMessage(m.tableReadController.Mark(controllers.MarkOpToggle, ""))
|
||||
case key.Matches(msg, m.keyMap.CopyItemToClipboard):
|
||||
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
|
||||
return m, events.SetTeaMessage(m.tableReadController.CopyItemToClipboard(idx))
|
||||
}
|
||||
case key.Matches(msg, m.keyMap.CopyTableToClipboard):
|
||||
return m, events.SetTeaMessage(m.exportController.ExportCSVToClipboard())
|
||||
case key.Matches(msg, m.keyMap.Rescan):
|
||||
return m, m.tableReadController.Rescan
|
||||
case key.Matches(msg, m.keyMap.PromptForQuery):
|
||||
|
|
|
|||
|
|
@ -9,14 +9,17 @@ import (
|
|||
"github.com/lmika/dynamo-browse/internal/common/ui/events"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/layout"
|
||||
"github.com/lmika/dynamo-browse/internal/dynamo-browse/ui/teamodels/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt
|
||||
// event is received, focus will be torn away and the user will be given a prompt the enter text.
|
||||
type StatusAndPrompt struct {
|
||||
model layout.ResizingModel
|
||||
pasteboardProvider PasteboardProvider
|
||||
style Style
|
||||
modeLine string
|
||||
rightModeLine string
|
||||
statusMessage string
|
||||
spinner spinner.Model
|
||||
spinnerVisible bool
|
||||
|
|
@ -30,15 +33,17 @@ type Style struct {
|
|||
ModeLine lipgloss.Style
|
||||
}
|
||||
|
||||
func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt {
|
||||
func New(model layout.ResizingModel, pasteboardProvider PasteboardProvider, initialMsg string, style Style) *StatusAndPrompt {
|
||||
textInput := textinput.New()
|
||||
return &StatusAndPrompt{
|
||||
model: model,
|
||||
style: style,
|
||||
statusMessage: initialMsg,
|
||||
modeLine: "",
|
||||
spinner: spinner.New(spinner.WithSpinner(spinner.Line)),
|
||||
textInput: textInput,
|
||||
model: model,
|
||||
pasteboardProvider: pasteboardProvider,
|
||||
style: style,
|
||||
statusMessage: initialMsg,
|
||||
modeLine: "",
|
||||
rightModeLine: "",
|
||||
spinner: spinner.New(spinner.WithSpinner(spinner.Line)),
|
||||
textInput: textInput,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +78,11 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if hasModeMessage, ok := msg.(events.MessageWithMode); ok {
|
||||
s.modeLine = hasModeMessage.ModeMessage()
|
||||
}
|
||||
if rightModeMessage, ok := msg.(events.MessageWithRightMode); ok {
|
||||
s.rightModeLine = rightModeMessage.RightModeMessage()
|
||||
} else {
|
||||
s.rightModeLine = ""
|
||||
}
|
||||
s.statusMessage = msg.StatusMessage()
|
||||
case events.PromptForInputMsg:
|
||||
if s.pendingInput != nil {
|
||||
|
|
@ -96,6 +106,24 @@ func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
})
|
||||
}
|
||||
s.pendingInput = nil
|
||||
case tea.KeyCtrlV:
|
||||
if content, ok := s.pasteboardProvider.ReadText(); ok {
|
||||
pasteContent := strings.TrimSpace(content)
|
||||
|
||||
cursorPos := s.textInput.Cursor()
|
||||
beforeValue := s.textInput.Value()[:cursorPos] + pasteContent
|
||||
newValue := beforeValue + s.textInput.Value()[cursorPos:]
|
||||
|
||||
s.textInput.SetValue(newValue)
|
||||
s.textInput.SetCursor(len(beforeValue))
|
||||
}
|
||||
case tea.KeyTab:
|
||||
if tabCompletion := s.pendingInput.originalMsg.OnTabComplete; tabCompletion != nil {
|
||||
if completion, ok := tabCompletion(s.textInput.Value()); ok {
|
||||
s.textInput.SetValue(completion)
|
||||
s.textInput.SetCursor(len(s.textInput.Value()))
|
||||
}
|
||||
}
|
||||
case tea.KeyEnter:
|
||||
pendingInput := s.pendingInput
|
||||
s.pendingInput = nil
|
||||
|
|
@ -176,7 +204,10 @@ func (s *StatusAndPrompt) Resize(w, h int) layout.ResizingModel {
|
|||
}
|
||||
|
||||
func (s *StatusAndPrompt) viewStatus() string {
|
||||
modeLine := s.style.ModeLine.Render(lipgloss.PlaceHorizontal(s.width, lipgloss.Left, s.modeLine, lipgloss.WithWhitespaceChars(" ")))
|
||||
rightModeLine := s.style.ModeLine.Render(s.rightModeLine)
|
||||
modeLine := s.style.ModeLine.Render(
|
||||
lipgloss.PlaceHorizontal(s.width-lipgloss.Width(rightModeLine), lipgloss.Left, s.modeLine, lipgloss.WithWhitespaceChars(" ")),
|
||||
) + rightModeLine
|
||||
|
||||
var statusLine string
|
||||
if s.pendingInput != nil {
|
||||
|
|
|
|||
|
|
@ -10,3 +10,7 @@ type pendingInputState struct {
|
|||
func newPendingInputState(msg events.PromptForInputMsg) *pendingInputState {
|
||||
return &pendingInputState{originalMsg: msg, historyIdx: -1}
|
||||
}
|
||||
|
||||
type PasteboardProvider interface {
|
||||
ReadText() (string, bool)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue