diff --git a/go.mod b/go.mod index 887aaf1..ac57b4e 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index d77e7b4..0aafeb1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs.go b/internal/common/ui/commandctrl/cmdpacks/modrs.go index 5f3060b..eea66b4 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs.go @@ -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, }, } } diff --git a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go index 113bd46..878ad3b 100644 --- a/internal/common/ui/commandctrl/cmdpacks/modrs_test.go +++ b/internal/common/ui/commandctrl/cmdpacks/modrs_test.go @@ -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) diff --git a/internal/common/ui/commandctrl/cmdpacks/proxy.go b/internal/common/ui/commandctrl/cmdpacks/proxy.go index 5606f94..6801946 100644 --- a/internal/common/ui/commandctrl/cmdpacks/proxy.go +++ b/internal/common/ui/commandctrl/cmdpacks/proxy.go @@ -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 { diff --git a/internal/common/ui/commandctrl/cmdpacks/pvars.go b/internal/common/ui/commandctrl/cmdpacks/pvars.go index d297a98..f07965a 100644 --- a/internal/common/ui/commandctrl/cmdpacks/pvars.go +++ b/internal/common/ui/commandctrl/cmdpacks/pvars.go @@ -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) diff --git a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go index 48dd158..44aff3e 100644 --- a/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go +++ b/internal/common/ui/commandctrl/cmdpacks/stdcmds_test.go @@ -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 } diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index 03d0421..ff4e398 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -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 +}