2

In my Rails app I have users who can have many payments.

class User < ActiveRecord::Base

  has_many :invoices
  has_many :payments

  def year_ranges
    ...
  end

  def quarter_ranges
    ...
  end

  def month_ranges
    ...
  end

  def revenue_between(range, kind)
    payments.sum_within_range(range, kind)
  end

end

class Invoice < ActiveRecord::Base

  belongs_to :user
  has_many :items
  has_many :payments

  ...

end

class Payment < ActiveRecord::Base

  belongs_to :user
  belongs_to :invoice

  def net_amount
    invoice.subtotal * percent_of_invoice_total / 100
  end  

  def taxable_amount
    invoice.total_tax * percent_of_invoice_total / 100
  end

  def gross_amount
    invoice.total * percent_of_invoice_total / 100
  end

  def self.chart_data(ranges, unit)
    ranges.map do |r| { 
      :range            => range_label(r, unit),
      :gross_revenue    => sum_within_range(r, :gross),
      :taxable_revenue  => sum_within_range(r, :taxable),
      :net_revenue      => sum_within_range(r, :net) }
    end
  end

  def self.sum_within_range(range, kind)
    @sum ||= includes(:invoice => :items)
    @sum.select { |x| range.cover? x.date }.sum(&:"#{kind}_amount")
  end

end

In my dashboard view I am listing the total payments for the ranges depending on the GET parameter that the user picked. The user can pick either years, quarters, or months.

class DashboardController < ApplicationController

  def show  
    if %w[year quarter month].include?(params[:by])   
      @unit = params[:by]
    else
      @unit = 'year'
    end
    @ranges = @user.send("#{@unit}_ranges")
    @paginated_ranges = @ranges.paginate(:page => params[:page], :per_page => 10)
    @title = "All your payments"
  end

end

The use of the instance variable (@sum) greatly reduced the number of SQL queries here because the database won't get hit for the same queries over and over again.

The problem is, however, that when a user creates, deletes or changes one of his payments, this is not reflected in the @sum instance variable. So how can I reset it? Or is there a better solution to this?

Thanks for any help.

4 Answers 4

3

This is incidental to your question, but don't use #select with a block.

What you're doing is selecting all payments, and then filtering the relation as an array. Use Arel to overcome this :

scope :within_range, ->(range){ where date: range }

This will build an SQL BETWEEN statement. Using #sum on the resulting relation will build an SQL SUM() statement, which is probably more efficient than loading all the records.

Sign up to request clarification or add additional context in comments.

4 Comments

The select is an ActiveRecord Query method, not Arrays. It's just another chain on scope.
nope, not when using it with a block. Try it yourself : it will load all the records.
proof : apidock.com/rails/ActiveRecord/QueryMethods/select . "with a block, works just like Array#select" see source...
this is a better solution if the method is called for few ranges in a single request.
3

Instead of storing the association as an instance variable of the Class Payment, store it as an instance variable of a user (I know it sounds confusing, I have tried to explain below)

class User < ActiveRecord::Base

  has_many :payments

  def revenue_between(range)
    @payments_with_invoices ||= payments.includes(:invoice => :items).all
    # @payments_with_invoices is an array now so cannot use Payment's class method on it
    @payments_with_invoices.select { |x| range.cover? x.date }.sum(&:total)
  end

end

When you defined @sum in a class method (class methods are denoted by self.) it became an instance variable of Class Payment. That means you can potentially access it as Payment.sum. So this has nothing to do with a particular user and his/her payments. @sum is now an attribute of the class Payment and Rails would cache it the same way it caches the method definitions of a class.

Once @sum is initialized, it will stay the same, as you noticed, even after user creates new payment or if a different user logs in for that matter! It will change when the app is restarted.

However, if you define @payments_with_invoiceslike I show above, it becomes an attribute of a particular instance of User or in other words instance level instance variable. That means you can potentially access it as some_user.payments_with_invoices. Since an app can have many users these are not persisted in Rails memory across requests. So whenever the user instance changes its attributes are loaded again.

So if the user creates more payments the @payments_with_invoices variable would be refreshed since the user instance is re-initialized.

8 Comments

+1, good workaround. Though i would let sum_within_range do all the calculations using SQL (see my answer)
@m_x ya usually that would be better, but I had some bkgrnd due to a prev question. This function is being called numerous times in same request for overlapping ranges so I thought it is better to do the filtering in Rails. But if that is not the case anymore your solution makes more sense :)
Thanks. When I copy your exact code, I still get around 80 SQL queries. So I moved includes(:invoice => :items) to the beginning of the select method in the Payment class and now I get only around 10 SQL queries per page (which is the same as before and thus fine), however the total rendering time has increased an awful lot, from around 300 ms to around 3000 ms. That's definitely too much and I wonder why it is.
try @m_x's suggestion it might be faster.
I updated my solution as well for consistency but do not think it will speed it up
|
0

Maybe you could do it with observers:

# payment.rb

def self.cached_sum(force=false)
  if @sum.blank? || force
    @sum = includes(:invoice => :items)
  end
  @sum
end

def self.sum_within_range(range)
  @sum = cached_sum
  @sum.select { |x| range.cover? x.date }.sum(&total)
end

#payment_observer.rb

class PaymentObserver < ActiveRecord::Observer
  # force @sum updating

  def after_save(comment)
    Payment.cached_sum(true)
  end

  def after_destroy(comment)
    Payment.cached_sum(true)
  end

end

You could find more about observers at http://apidock.com/rails/v3.2.13/ActiveRecord/Observer

Comments

0

Well your @sum is basically a cache of the values you need. Like any cache, you need to invalidate it if something happens to the values involved.

You could use after_save or after_create filters to call a function which sets @sum = nil. It may also be useful to also save the range your cache is covering and decide the invalidation by the date of the new or changed payment.

class Payment < ActiveRecord::Base

  belongs_to :user

  after_save :invalidate_cache

  def self.sum_within_range(range)
    @cached_range = range
    @sum ||= includes(:invoice => :items)
    @sum.select { |x| range.cover? x.date }.sum(&total)
  end

  def self.invalidate_cache
    @sum = nil if @cached_range.includes?(payment_date)
end

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.