diff --git a/cmd/api/handler/responses/rollup.go b/cmd/api/handler/responses/rollup.go index 1dd78f49..feba0b5b 100644 --- a/cmd/api/handler/responses/rollup.go +++ b/cmd/api/handler/responses/rollup.go @@ -204,3 +204,21 @@ func NewRollupWithDayStats(r storage.RollupWithDayStats) RollupWithDayStats { return response } + +type RollupGroupedStats struct { + Fee float64 `example:"123.456789" format:"string" json:"fee" swaggertype:"string"` + Size float64 `example:"1000" format:"integer" json:"size" swaggertype:"integer"` + BlobsCount int64 `example:"2" format:"integer" json:"blobs_count" swaggertype:"integer"` + Group string `example:"group" format:"string" json:"group" swaggertype:"string"` +} + +func NewRollupGroupedStats(r storage.RollupGroupedStats) RollupGroupedStats { + response := RollupGroupedStats{ + Fee: r.Fee, + Size: r.Size, + BlobsCount: r.BlobsCount, + Group: r.Group, + } + + return response +} diff --git a/cmd/api/handler/rollup.go b/cmd/api/handler/rollup.go index 960bc323..f5372bba 100644 --- a/cmd/api/handler/rollup.go +++ b/cmd/api/handler/rollup.go @@ -551,3 +551,43 @@ func (handler RollupHandler) ExportBlobs(c echo.Context) error { } return nil } + +type rollupGroupStats struct { + Func string `query:"func" validate:"oneof=sum avg"` + Column string `query:"column" validate:"oneof=stack type category vm provider"` +} + +// RollupGroupedStats godoc +// +// @Summary Rollup Grouped Statistics +// @Description Rollup Grouped Statistics +// @Tags rollup +// @ID rollup-grouped-statistics +// @Param func query string false "Aggregate function" Enums(sum, avg) +// @Param column query string false "Group column" Enums(stack, type, category, vm, provider) +// @Produce json +// @Success 200 {array} responses.RollupGroupedStats +// @Failure 400 {object} Error +// @Failure 500 {object} Error +// @Router /rollup/group [get] +func (handler RollupHandler) RollupGroupedStats(c echo.Context) error { + req, err := bindAndValidate[rollupGroupStats](c) + if err != nil { + return badRequestError(c, err) + } + + rollups, err := handler.rollups.RollupStatsGrouping(c.Request().Context(), storage.RollupGroupStatsFilters{ + Func: req.Func, + Column: req.Column, + }) + if err != nil { + return handleError(c, err, handler.rollups) + } + + response := make([]responses.RollupGroupedStats, len(rollups)) + for i := range rollups { + response[i] = responses.NewRollupGroupedStats(rollups[i]) + } + + return returnArray(c, response) +} diff --git a/cmd/api/handler/rollup_test.go b/cmd/api/handler/rollup_test.go index 49327e4f..9aea8b2a 100644 --- a/cmd/api/handler/rollup_test.go +++ b/cmd/api/handler/rollup_test.go @@ -47,6 +47,12 @@ var ( SizePct: 0.3, }, } + testRollupWithGroupedStats = storage.RollupGroupedStats{ + Fee: 0.1, + Size: 0.2, + BlobsCount: 3, + Group: "stack", + } ) // RollupTestSuite - @@ -470,3 +476,49 @@ func (s *RollupTestSuite) TestAllSeries() { s.Require().EqualValues(1, item.BlobsCount) s.Require().EqualValues(testTime, item.Time) } + +func (s *RollupTestSuite) TestRollupStatsGrouping() { + for _, funcName := range []string{ + "sum", + "avg", + } { + for _, groupName := range []string{ + "stack", + "type", + "category", + "vm", + "provider", + } { + q := make(url.Values) + q.Add("func", funcName) + q.Add("column", groupName) + + req := httptest.NewRequest(http.MethodGet, "/?"+q.Encode(), nil) + rec := httptest.NewRecorder() + c := s.echo.NewContext(req, rec) + c.SetPath("/rollup/group") + + s.rollups.EXPECT(). + RollupStatsGrouping(gomock.Any(), storage.RollupGroupStatsFilters{ + Func: funcName, + Column: groupName, + }). + Return([]storage.RollupGroupedStats{testRollupWithGroupedStats}, nil). + Times(1) + + s.Require().NoError(s.handler.RollupGroupedStats(c)) + s.Require().Equal(http.StatusOK, rec.Code) + var stats []responses.RollupGroupedStats + err := json.NewDecoder(rec.Body).Decode(&stats) + s.Require().NoError(err) + s.Require().Len(stats, 1) + + groupedStats := stats[0] + + s.Require().EqualValues(0.1, groupedStats.Fee) + s.Require().EqualValues(0.2, groupedStats.Size) + s.Require().EqualValues(3, groupedStats.BlobsCount) + s.Require().EqualValues("stack", groupedStats.Group) + } + } +} diff --git a/cmd/api/init.go b/cmd/api/init.go index 9298708f..c8e54fb1 100644 --- a/cmd/api/init.go +++ b/cmd/api/init.go @@ -485,6 +485,7 @@ func initHandlers(ctx context.Context, e *echo.Echo, cfg Config, db postgres.Sto rollups.GET("", rollupHandler.Leaderboard) rollups.GET("/count", rollupHandler.Count) rollups.GET("/day", rollupHandler.LeaderboardDay) + rollups.GET("/group", rollupHandler.RollupGroupedStats) rollups.GET("/stats/series", rollupHandler.AllSeries) rollups.GET("/slug/:slug", rollupHandler.BySlug) rollup := rollups.Group("/:id") diff --git a/cmd/api/routes_test.go b/cmd/api/routes_test.go index ed115dcc..fe66325e 100644 --- a/cmd/api/routes_test.go +++ b/cmd/api/routes_test.go @@ -103,6 +103,7 @@ func TestRoutes(t *testing.T) { "/v1/stats/rollup_stats_24h GET": {}, "/v1/stats/messages_count_24h GET": {}, "/v1/rollup/stats/series GET": {}, + "/v1/rollup/group GET": {}, } ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/storage/mock/rollup.go b/internal/storage/mock/rollup.go index 4d1879d5..a10bc355 100644 --- a/internal/storage/mock/rollup.go +++ b/internal/storage/mock/rollup.go @@ -25,6 +25,7 @@ import ( type MockIRollup struct { ctrl *gomock.Controller recorder *MockIRollupMockRecorder + isgomock struct{} } // MockIRollupMockRecorder is the mock recorder for MockIRollup. @@ -589,6 +590,45 @@ func (c *MockIRollupProvidersCall) DoAndReturn(f func(context.Context, uint64) ( return c } +// RollupStatsGrouping mocks base method. +func (m *MockIRollup) RollupStatsGrouping(ctx context.Context, fltrs storage.RollupGroupStatsFilters) ([]storage.RollupGroupedStats, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RollupStatsGrouping", ctx, fltrs) + ret0, _ := ret[0].([]storage.RollupGroupedStats) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RollupStatsGrouping indicates an expected call of RollupStatsGrouping. +func (mr *MockIRollupMockRecorder) RollupStatsGrouping(ctx, fltrs any) *MockIRollupRollupStatsGroupingCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RollupStatsGrouping", reflect.TypeOf((*MockIRollup)(nil).RollupStatsGrouping), ctx, fltrs) + return &MockIRollupRollupStatsGroupingCall{Call: call} +} + +// MockIRollupRollupStatsGroupingCall wrap *gomock.Call +type MockIRollupRollupStatsGroupingCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockIRollupRollupStatsGroupingCall) Return(arg0 []storage.RollupGroupedStats, arg1 error) *MockIRollupRollupStatsGroupingCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockIRollupRollupStatsGroupingCall) Do(f func(context.Context, storage.RollupGroupStatsFilters) ([]storage.RollupGroupedStats, error)) *MockIRollupRollupStatsGroupingCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockIRollupRollupStatsGroupingCall) DoAndReturn(f func(context.Context, storage.RollupGroupStatsFilters) ([]storage.RollupGroupedStats, error)) *MockIRollupRollupStatsGroupingCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // RollupsByNamespace mocks base method. func (m *MockIRollup) RollupsByNamespace(ctx context.Context, namespaceId uint64, limit, offset int) ([]storage.Rollup, error) { m.ctrl.T.Helper() diff --git a/internal/storage/postgres/rollup.go b/internal/storage/postgres/rollup.go index 401e1cf5..eb6e345c 100644 --- a/internal/storage/postgres/rollup.go +++ b/internal/storage/postgres/rollup.go @@ -296,3 +296,32 @@ func (r *Rollup) AllSeries(ctx context.Context) (items []storage.RollupHistogram return } + +func (r *Rollup) RollupStatsGrouping(ctx context.Context, fltrs storage.RollupGroupStatsFilters) (results []storage.RollupGroupedStats, err error) { + query := r.DB().NewSelect().Table(storage.ViewLeaderboard) + + switch fltrs.Func { + case "sum": + query = query. + ColumnExpr("sum(fee) as fee"). + ColumnExpr("sum(size) as size"). + ColumnExpr("sum(blobs_count) as blobs_count") + case "avg": + query = query. + ColumnExpr("avg(fee) as fee"). + ColumnExpr("avg(size) as size"). + ColumnExpr("avg(blobs_count) as blobs_count") + default: + return nil, errors.Errorf("unknown func field: %s", fltrs.Column) + } + + switch fltrs.Column { + case "stack", "type", "category", "vm", "provider": + query = query.ColumnExpr(fltrs.Column + " as group").Group(fltrs.Column) + default: + return nil, errors.Errorf("unknown column field: %s", fltrs.Column) + } + + err = query.Scan(ctx, &results) + return +} diff --git a/internal/storage/postgres/rollup_test.go b/internal/storage/postgres/rollup_test.go index fd2d0d52..8054ef6a 100644 --- a/internal/storage/postgres/rollup_test.go +++ b/internal/storage/postgres/rollup_test.go @@ -261,3 +261,25 @@ func (s *StorageTestSuite) TestRollupAllSeries() { s.Require().NoError(err) s.Require().Len(items, 5) } + +func (s *StorageTestSuite) TestRollupStatsGrouping() { + ctx, ctxCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer ctxCancel() + + _, err := s.storage.Connection().Exec(ctx, "REFRESH MATERIALIZED VIEW leaderboard;") + s.Require().NoError(err) + + column := "stack" + rollups, err := s.storage.Rollup.RollupStatsGrouping(ctx, storage.RollupGroupStatsFilters{ + Func: "sum", + Column: column, + }) + s.Require().NoError(err, column) + s.Require().Len(rollups, 2, column) + + rollup := rollups[1] + s.Require().EqualValues(4000, rollup.Fee, column) + s.Require().EqualValues(52, rollup.Size, column) + s.Require().EqualValues(4, rollup.BlobsCount, column) + s.Require().EqualValues("OP Stack", rollup.Group, column) +} diff --git a/internal/storage/rollup.go b/internal/storage/rollup.go index 27b7c6cd..45de98a4 100644 --- a/internal/storage/rollup.go +++ b/internal/storage/rollup.go @@ -22,6 +22,11 @@ type LeaderboardFilters struct { Type []types.RollupType } +type RollupGroupStatsFilters struct { + Func string + Column string +} + //go:generate mockgen -source=$GOFILE -destination=mock/$GOFILE -package=mock -typed type IRollup interface { sdk.Table[*Rollup] @@ -37,6 +42,7 @@ type IRollup interface { Count(ctx context.Context) (int64, error) Distribution(ctx context.Context, rollupId uint64, series, groupBy string) (items []DistributionItem, err error) BySlug(ctx context.Context, slug string) (RollupWithStats, error) + RollupStatsGrouping(ctx context.Context, fltrs RollupGroupStatsFilters) ([]RollupGroupedStats, error) } // Rollup - @@ -129,3 +135,10 @@ type RollupHistogramItem struct { Logo string `bun:"logo"` Time time.Time `bun:"time"` } + +type RollupGroupedStats struct { + Fee float64 `bun:"fee"` + Size float64 `bun:"size"` + BlobsCount int64 `bun:"blobs_count"` + Group string `bun:"group"` +} diff --git a/test/data/rollup.yml b/test/data/rollup.yml index d1e9e3df..3060581a 100644 --- a/test/data/rollup.yml +++ b/test/data/rollup.yml @@ -7,6 +7,7 @@ logo: https://rollup1.com/image.png slug: rollup_1 category: finance + stack: OP Stack type: settled - id: 2 name: Rollup 2 @@ -17,6 +18,7 @@ logo: https://rollup2.com/image.png slug: rollup_2 category: gaming + stack: OP Stack type: settled - id: 3 name: Rollup 3 @@ -27,4 +29,5 @@ logo: https://rollup3.com/image.png slug: rollup_3 category: nft + stack: Custom Stack type: sovereign