Skip to content

Commit

Permalink
feat: reduce DB calls in plan list API (#782)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
anujk14 authored Sep 30, 2024
1 parent 9ee6640 commit cfd0de7
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 19 deletions.
66 changes: 48 additions & 18 deletions billing/plan/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
5 changes: 4 additions & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
185 changes: 185 additions & 0 deletions internal/store/postgres/billing_plan_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

0 comments on commit cfd0de7

Please sign in to comment.