ucl: added more resultset functions

This commit is contained in:
Leon Mika 2025-05-19 22:14:22 +10:00
parent 40f8dd76e2
commit 5088009672
8 changed files with 369 additions and 69 deletions

2
go.mod
View file

@ -117,5 +117,5 @@ require (
golang.org/x/text v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78 // indirect
ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8 // indirect
)

6
go.sum
View file

@ -454,3 +454,9 @@ ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94 h1:x3IRtT1jbedblimi2hesKGBihg24
ucl.lmika.dev v0.0.0-20250518024533-f4be44fcbc94/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78 h1:lbOZUb6whYMLI4win5QL+eLSgqc3N9TtTgT8hTipNl8=
ucl.lmika.dev v0.0.0-20250518033831-f79e91e26d78/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3 h1:ZMQ1rkcAWa///c3bVvlXbtuqjfAWxDm01abQl3g/YVw=
ucl.lmika.dev v0.0.0-20250519111943-1173d163f5e3/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a h1:dzBBFCY50+MQcJaQ90swdDyjzag5oIhwdfqbmZkvX3Q=
ucl.lmika.dev v0.0.0-20250519114239-7ca821016e9a/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=
ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8 h1:h32JQi0d1MI86RaAMaEU7kvti4uSLX5XYe/nk2abApg=
ucl.lmika.dev v0.0.0-20250519120409-53b05b5ba6f8/go.mod h1:/MMZKm6mOMtnY4I8TYEot4Pc8dKEy+/IAQo1VdpA5EY=

View file

@ -129,6 +129,84 @@ func (rs *rsModule) rsQuery(ctx context.Context, args ucl.CallArgs) (any, error)
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
}
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 moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module {
m := &rsModule{
tableService: tableService,
@ -138,8 +216,11 @@ func moduleRS(tableService *tables.Service, state *controllers.State) ucl.Module
return ucl.Module{
Name: "rs",
Builtins: map[string]ucl.BuiltinHandler{
"new": m.rsNew,
"query": m.rsQuery,
"new": m.rsNew,
"query": m.rsQuery,
"scan": m.rsScan,
"next-page": m.rsNextPage,
"union": m.rsUnion,
},
}
}

View file

@ -3,6 +3,7 @@ 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"
)
@ -13,7 +14,80 @@ func TestModRS_New(t *testing.T) {
rsProxy, err := svc.CommandController.ExecuteAndWait(t.Context(), `rs:new`)
assert.NoError(t, err)
assert.IsType(t, rsProxy, &cmdpacks.ResultSetProxy{})
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) {
@ -65,11 +139,11 @@ func TestModRS_Query(t *testing.T) {
res, err := svc.CommandController.ExecuteAndWait(t.Context(), tt.cmd)
assert.NoError(t, err)
rs := res.(*cmdpacks.ResultSetProxy).RS
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 testData[0].Data[rowIndex] {
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

@ -9,36 +9,50 @@ import (
"ucl.lmika.dev/ucl"
)
type proxyFields[T any] map[string]func(t T) ucl.Object
type simpleProxy[T comparable] struct {
value T
fields proxyFields[T]
type proxyInfo[T comparable] struct {
fields map[string]func(t T) ucl.Object
lenFunc func(t T) int
strFunc func(t T) string
}
func (tp simpleProxy[T]) String() 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 {
func (tp SimpleProxy[T]) Truthy() bool {
var zeroT T
return tp.value != zeroT
}
func (tp simpleProxy[T]) Len() int {
return len(tp.fields)
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.fields[k]
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.fields) {
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
}
@ -72,41 +86,63 @@ func (tp simpleProxyList[T]) Index(k int) ucl.Object {
}
func newResultSetProxy(rs *models.ResultSet) ucl.Object {
return simpleProxy[*models.ResultSet]{value: rs, fields: resultSetProxyFields}
return SimpleProxy[*models.ResultSet]{value: rs, proxyInfo: resultSetProxyFields}
}
var resultSetProxyFields = proxyFields[*models.ResultSet]{
"Table": func(t *models.ResultSet) ucl.Object { return newTableProxy(t.TableInfo) },
"Items": func(t *models.ResultSet) ucl.Object { return resultSetItemsProxy{t} },
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, fields: tableProxyFields}
return SimpleProxy[*models.TableInfo]{value: table, proxyInfo: tableProxyFields}
}
var tableProxyFields = proxyFields[*models.TableInfo]{
"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) },
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, fields: keyAttributeProxyFields}
return SimpleProxy[models.KeyAttribute]{value: keyAttrs, proxyInfo: keyAttributeProxyFields}
}
var keyAttributeProxyFields = proxyFields[models.KeyAttribute]{
"PartitionKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) },
"SortKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) },
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{
"PartitionKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.PartitionKey) },
"SortKey": func(t models.KeyAttribute) ucl.Object { return ucl.StringObject(t.SortKey) },
},
}
func newGSIProxy(gsi models.TableGSI) ucl.Object {
return simpleProxy[models.TableGSI]{value: gsi, fields: gsiProxyFields}
return SimpleProxy[models.TableGSI]{value: gsi, proxyInfo: gsiProxyFields}
}
var gsiProxyFields = proxyFields[models.TableGSI]{
"Name": func(t models.TableGSI) ucl.Object { return ucl.StringObject(t.Name) },
"Keys": func(t models.TableGSI) ucl.Object { return newKeyAttributeProxy(t.Keys) },
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 {
@ -114,7 +150,7 @@ type resultSetItemsProxy struct {
}
func (ip resultSetItemsProxy) String() string {
return "items"
return "RSItem()"
}
func (ip resultSetItemsProxy) Truthy() bool {
@ -136,7 +172,7 @@ type itemProxy struct {
}
func (ip itemProxy) String() string {
return "item"
return fmt.Sprintf("RSItems(%v)", len(ip.item))
}
func (ip itemProxy) Truthy() bool {

View file

@ -26,9 +26,9 @@ func (rs resultSetPVar) Get(ctx context.Context) (any, error) {
}
func (rs resultSetPVar) Set(ctx context.Context, value any) error {
rsVal, ok := value.(simpleProxy[*models.ResultSet])
rsVal, ok := value.(SimpleProxy[*models.ResultSet])
if !ok {
return errors.New("new value to @resultset is not a result set")
return errors.New("new value to @resultset is nil or not a result set")
}
msg := rs.readController.SetResultSet(rsVal.value)

View file

@ -1,6 +1,8 @@
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"
@ -48,14 +50,41 @@ func TestStdCmds_Mark(t *testing.T) {
}
}
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
}
func newService(t *testing.T) *services {
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)
@ -66,7 +95,18 @@ func newService(t *testing.T) *services {
itemRendererService := itemrenderer.NewService(itemrenderer.PlainTextRenderer(), itemrenderer.PlainTextRenderer())
inputHistoryService := inputhistory.New(inputHistoryStore)
client := testdynamo.SetupTestTable(t, testData)
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)
@ -74,6 +114,7 @@ func newService(t *testing.T) *services {
state := controllers.NewState()
jobsController := controllers.NewJobsController(jobs.NewService(eventBus), eventBus, true)
readController := controllers.NewTableReadController(
state,
service,
@ -84,7 +125,7 @@ func newService(t *testing.T) *services {
eventBus,
pasteboardprovider.NilProvider{},
nil,
"service-test-data",
s.table,
)
writeController := controllers.NewTableWriteController(state, service, jobsController, readController, settingStore)
settingsController := controllers.NewSettingsController(settingStore, eventBus)
@ -102,10 +143,8 @@ func newService(t *testing.T) *services {
},
)
s := &services{
State: state,
CommandController: commandController,
}
s.State = state
s.CommandController = commandController
commandController.SetUIStateProvider(s)
readController.Init()
@ -117,27 +156,57 @@ func (s *services) SelectedItemIndex() int {
return s.SelItemIndex
}
var testData = []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 (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

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