From cfd0de7348e40b0c8daf4554c220638433fd6db1 Mon Sep 17 00:00:00 2001 From: Anuj Khandelwal Date: Mon, 30 Sep 2024 14:56:34 +0530 Subject: [PATCH] feat: reduce DB calls in plan list API (#782) * wip commit * fetch products with plans. feature fetch pending * populate features into plan list result * fix var names * handle error in service, do not mutate map in method --- billing/plan/service.go | 66 +++++-- cmd/serve.go | 5 +- .../store/postgres/billing_plan_repository.go | 185 ++++++++++++++++++ 3 files changed, 237 insertions(+), 19 deletions(-) diff --git a/billing/plan/service.go b/billing/plan/service.go index 2b9a19635..21c769674 100644 --- a/billing/plan/service.go +++ b/billing/plan/service.go @@ -20,6 +20,7 @@ type Repository interface { Create(ctx context.Context, plan Plan) (Plan, error) UpdateByName(ctx context.Context, plan Plan) (Plan, error) List(ctx context.Context, filter Filter) ([]Plan, error) + ListWithProducts(ctx context.Context, filter Filter) ([]Plan, error) } type ProductService interface { @@ -40,17 +41,23 @@ type ProductService interface { GetFeatureByProductID(ctx context.Context, id string) ([]product.Feature, error) } +type FeatureRepository interface { + List(ctx context.Context, flt product.Filter) ([]product.Feature, error) +} + type Service struct { - planRepository Repository - stripeClient *client.API - productService ProductService + planRepository Repository + stripeClient *client.API + productService ProductService + featureRepository FeatureRepository } -func NewService(stripeClient *client.API, planRepository Repository, productService ProductService) *Service { +func NewService(stripeClient *client.API, planRepository Repository, productService ProductService, featureRepository FeatureRepository) *Service { return &Service{ - stripeClient: stripeClient, - planRepository: planRepository, - productService: productService, + stripeClient: stripeClient, + planRepository: planRepository, + productService: productService, + featureRepository: featureRepository, } } @@ -84,22 +91,26 @@ func (s Service) GetByID(ctx context.Context, id string) (Plan, error) { } func (s Service) List(ctx context.Context, filter Filter) ([]Plan, error) { - listedPlans, err := s.planRepository.List(ctx, filter) + plans, err := s.planRepository.ListWithProducts(ctx, filter) if err != nil { return nil, err } - // enrich with product - for i, listedPlan := range listedPlans { - // TODO(kushsharma): we can do this in one query - products, err := s.productService.List(ctx, product.Filter{ - PlanID: listedPlan.ID, - }) - if err != nil { - return nil, err + + features, err := s.featureRepository.List(ctx, product.Filter{}) + if err != nil { + return nil, err + } + + // Populate a map initialized with features that belong to a product + productFeatureMapping := mapFeaturesToProducts(plans, features) + + for _, plan := range plans { + for i, prod := range plan.Products { + plan.Products[i].Features = productFeatureMapping[prod.ID] } - listedPlans[i].Products = products } - return listedPlans, nil + + return plans, nil } func (s Service) UpsertPlans(ctx context.Context, planFile File) error { @@ -327,3 +338,22 @@ func verifyDuplicatePlans(planFile File) error { } return nil } + +func mapFeaturesToProducts(p []Plan, features []product.Feature) map[string][]product.Feature { + productFeatures := map[string][]product.Feature{} + for _, pln := range p { + products := pln.Products + for _, prod := range products { + productFeatures[prod.ID] = []product.Feature{} + } + } + + for _, feature := range features { + productIDs := feature.ProductIDs + for _, productID := range productIDs { + productFeatures[productID] = append(productFeatures[productID], feature) + } + } + + return productFeatures +} diff --git a/cmd/serve.go b/cmd/serve.go index 33d3cb2a8..e004f607a 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -419,16 +419,19 @@ func buildAPIDependencies( customerService := customer.NewService( stripeClient, postgres.NewBillingCustomerRepository(dbc), cfg.Billing) + + featureRepository := postgres.NewBillingFeatureRepository(dbc) productService := product.NewService( stripeClient, postgres.NewBillingProductRepository(dbc), postgres.NewBillingPriceRepository(dbc), - postgres.NewBillingFeatureRepository(dbc), + featureRepository, ) planService := plan.NewService( stripeClient, postgres.NewBillingPlanRepository(dbc), productService, + featureRepository, ) creditService := credit.NewService(postgres.NewBillingTransactionRepository(dbc)) subscriptionService := subscription.NewService( diff --git a/internal/store/postgres/billing_plan_repository.go b/internal/store/postgres/billing_plan_repository.go index 1043ee923..2fb7e2f1b 100644 --- a/internal/store/postgres/billing_plan_repository.go +++ b/internal/store/postgres/billing_plan_repository.go @@ -13,7 +13,9 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/jmoiron/sqlx/types" + "github.com/lib/pq" "github.com/raystack/frontier/billing/plan" + "github.com/raystack/frontier/billing/product" "github.com/raystack/frontier/pkg/db" ) @@ -35,6 +37,80 @@ type Plan struct { DeletedAt *time.Time `db:"deleted_at"` } +type PlanProductRow struct { + PlanID string `db:"plan_id"` + + PlanName string `db:"plan_name"` + PlanTitle *string `db:"plan_title"` + PlanDescription *string `db:"plan_description"` + PlanInterval *string `db:"plan_interval"` + PlanOnStartCredits int64 `db:"plan_on_start_credits"` + + PlanState string `db:"plan_state"` + PlanTrialDays *int64 `db:"plan_trial_days"` + PlanMetadata types.NullJSONText `db:"plan_metadata"` + + PlanCreatedAt time.Time `db:"plan_created_at"` + PlanUpdatedAt time.Time `db:"plan_updated_at"` + PlanDeletedAt *time.Time `db:"plan_deleted_at"` + + ProductID string `db:"product_id"` + ProductProviderID string `db:"product_provider_id"` + ProductPlanIDs pq.StringArray `db:"product_plan_ids"` + ProductName string `db:"product_name"` + ProductTitle *string `db:"product_title"` + ProductDescription *string `db:"product_description"` + + ProductBehavior string `db:"product_behavior"` + ProductConfig BehaviorConfig `db:"product_config"` + ProductState string `db:"product_state"` + ProductMetadata types.NullJSONText `db:"product_metadata"` + + ProductCreatedAt time.Time `db:"product_created_at"` + ProductUpdatedAt time.Time `db:"product_updated_at"` + ProductDeletedAt *time.Time `db:"product_deleted_at"` +} + +func (pr PlanProductRow) getPlan() (plan.Plan, error) { + pln := Plan{ + ID: pr.PlanID, + Name: pr.PlanName, + Title: pr.PlanTitle, + Description: pr.PlanDescription, + Interval: pr.PlanInterval, + OnStartCredits: pr.PlanOnStartCredits, + State: pr.PlanState, + TrialDays: pr.PlanTrialDays, + Metadata: pr.PlanMetadata, + + CreatedAt: pr.PlanCreatedAt, + UpdatedAt: pr.PlanUpdatedAt, + DeletedAt: pr.PlanDeletedAt, + } + + return pln.transform() +} + +func (pr PlanProductRow) getProduct() (product.Product, error) { + prod := Product{ + ID: pr.ProductID, + ProviderID: pr.ProductProviderID, + PlanIDs: pr.ProductPlanIDs, + Name: pr.ProductName, + Title: pr.ProductTitle, + Description: pr.ProductDescription, + Behavior: pr.ProductBehavior, + Config: pr.ProductConfig, + State: pr.ProductState, + Metadata: pr.ProductMetadata, + CreatedAt: pr.ProductCreatedAt, + UpdatedAt: pr.ProductUpdatedAt, + DeletedAt: pr.ProductDeletedAt, + } + + return prod.transform() +} + func (c Plan) transform() (plan.Plan, error) { var unmarshalledMetadata map[string]any if c.Metadata.Valid { @@ -273,3 +349,112 @@ func (r BillingPlanRepository) List(ctx context.Context, filter plan.Filter) ([] } return plans, nil } + +func (r BillingPlanRepository) ListWithProducts(ctx context.Context, filter plan.Filter) ([]plan.Plan, error) { + pln := goqu.T(TABLE_BILLING_PLANS).As("plan") + prd := goqu.T(TABLE_BILLING_PRODUCTS).As("product") + stmt := dialect.From(pln). + Join( + prd, + goqu.On( + goqu.L("CAST(plan.id AS text)").Eq(goqu.L("ANY(product.plan_ids)")), + ), + ).Select( + pln.Col("id").As("plan_id"), + pln.Col("name").As("plan_name"), + pln.Col("title").As("plan_title"), + pln.Col("description").As("plan_description"), + pln.Col("interval").As("plan_interval"), + pln.Col("on_start_credits").As("plan_on_start_credits"), + pln.Col("state").As("plan_state"), + pln.Col("trial_days").As("plan_trial_days"), + pln.Col("metadata").As("plan_metadata"), + pln.Col("created_at").As("plan_created_at"), + pln.Col("updated_at").As("plan_updated_at"), + prd.Col("deleted_at").As("plan_deleted_at"), + prd.Col("id").As("product_id"), + prd.Col("provider_id").As("product_provider_id"), + prd.Col("name").As("product_name"), + prd.Col("title").As("product_title"), + prd.Col("description").As("product_description"), + prd.Col("title").As("product_behavior"), + prd.Col("config").As("product_config"), + prd.Col("state").As("product_state"), + prd.Col("metadata").As("product_metadata"), + prd.Col("created_at").As("product_created_at"), + prd.Col("updated_at").As("product_updated_at"), + prd.Col("deleted_at").As("product_deleted_at"), + ) + + var ids []string + var names []string + if len(filter.IDs) > 0 { + if _, err := uuid.Parse(filter.IDs[0]); err == nil { + ids = filter.IDs + } else { + names = filter.IDs + } + } + if len(ids) > 0 { + stmt = stmt.Where(goqu.Ex{ + "plan.id": ids, + }) + } + if len(names) > 0 { + stmt = stmt.Where(goqu.Ex{ + "plan.name": names, + }) + } + if filter.Interval != "" { + stmt = stmt.Where(goqu.Ex{ + "plan.interval": filter.Interval, + }) + } + if filter.State == "" { + filter.State = "active" + } + stmt = stmt.Where(goqu.Ex{ + "plan.state": filter.State, + }) + + query, params, err := stmt.ToSQL() + if err != nil { + return nil, fmt.Errorf("%w: %s", parseErr, err) + } + + var planProductRows []PlanProductRow + if err = r.dbc.WithTimeout(ctx, TABLE_BILLING_PLANS, "List", func(ctx context.Context) error { + return r.dbc.SelectContext(ctx, &planProductRows, query, params...) + }); err != nil { + return nil, fmt.Errorf("%w: %s", dbErr, err) + } + + planMap := map[string]plan.Plan{} + + for _, row := range planProductRows { + pln, err := row.getPlan() + if err != nil { + return nil, err + } + + prod, err := row.getProduct() + if err != nil { + return nil, err + } + + planInMap, exists := planMap[pln.ID] + if exists { + planInMap.Products = append(planInMap.Products, prod) + } else { + pln.Products = append(pln.Products, prod) + planMap[pln.ID] = pln + } + } + + plans := []plan.Plan{} + for _, item := range planMap { + plans = append(plans, item) + } + + return plans, nil +}