Tintin81
Tintin81

Reputation: 10207

How to update instance variable in Rails model?

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.

Upvotes: 2

Views: 3760

Answers (4)

tihom
tihom

Reputation: 8003

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.

Upvotes: 3

m_x
m_x

Reputation: 12554

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.

Upvotes: 3

KappaNossi
KappaNossi

Reputation: 2706

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

Upvotes: 0

hedgesky
hedgesky

Reputation: 3311

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

Upvotes: 0

Related Questions