How to update instance variable in Rails model? - ruby-on-rails

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.

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.

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.

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

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

Related

Rails create a Model for a controller that uses a .group method

I need to collect my customers with Spree::Order.group(:email) (since we have a guest checkout option).
The controller is as such:
class CustomersController < ApplicationController
def index
#customers = Spree::Order.where(state: "complete").group(:email).select(
:email,
'count(id) AS total_orders_count',
'sum(payment_total) AS amount',
'array_agg(number) AS order_numbers',
'array_agg(completed_at) AS completion_dates'
)
end
Can I create a customer.rb model for these #customers so I can move the logic there. I need to .joins(:line_items) and filter by date, so I figure it'd be cleaner.
P.S. As an asside...the 'sum(payment_total' AS amount always returns 0.0 because the payment_total is a BigDecimal object. What's the correct syntax for this request that would act like 'sum(payment_total.to_f)'....
Since you don't have a customers table, and you're trying to abstract the concept of a guest customer out of the orders table, creating a model is a reasonable approach.
Consider the following:
class Customer < ActiveRecord::Base
# Use the `orders` table for this "virtual" model
self.table_name = Spree::Order.table_name
# ensure no writes ever occur from this model as a precaution
def readonly?; true; end
scope :with_completed_orders, -> {
where(state: "complete")
.select(
:email,
'count(id) AS total_orders_count',
'sum(payment_total) AS amount',
'array_agg(number) AS order_numbers',
'array_agg(completed_at) AS completion_dates'
)
.group(:email)
.order(:email) # prevents errors - remove it and try Customer.first in the console.
}
scope :by_email, -> (email) { where(email: email) }
# Default scopes are generally a no-no, but a convenience here
def self.default_scope
# this could be any scope that groups by :email
self.with_completed_orders
end
# example of a potentially useful instance method
def orders
# returns a scope, which you can chain
Spree::Order.where(email: email)
end
end
The selects in the scope populate Customer instance attributes of the same name, as you probably know.
This will allow you to do things like:
customers = Customer.all
c = Customer.by_email('test#example.com')
c.amount # returns BigDecimal
c.order_numbers # returns Array
c.orders.first # example of chaining scope for #orders instance method
In the controller:
class CustomersController < ApplicationController
def index
#customers = Customer.all
end
end
Hopefully this gives you some ideas.

Issue on association in RAILS

Here is my issue. I have two models (Construction and Customer)
class Construction < ApplicationRecord
has_many :works
belongs_to :customer
end
class Customer < ApplicationRecord
has_many :constructions
end
I would like to associate a Customer to a Construction during the creation of a new construction.
To do so I have de following controller's method (which is obviously false)
def create
# #construction = Construction.new(constructions_params) (commented)
#construction = Construction.new(customer: #customer)
#customer = Customer.find(params[:customer_id])
#construction.save!
end
from the params I am able to understand that the construction is not saved because it is not attached to a customer and so cannot be created.
I am new to rails and I have been struggling for hours now..
Hope someone will be able to help me.
thanks a lot
Try to revert the order:
#customer = Customer.find(params[:construction][:customer_id])
#construction = Construction.new(customer: #customer)
#construction.save!
you need to assign #customer instance variable before you use it. Otherwise it's nil and nothing is assigned to the new Construction record.
If you have the customer_id available at the point of form creation I reckon that you can do something like this.
Also given the belongs_to relations with the customer on the construction, you should be able to update the customer_id on the construction.
def create
#construction = Construction.new(construction_params)
if #construction.save
# whatever you want to do on success
else
# Whatever you want to do on failure
end
end
# Given you have construction params
private
def construction_params
params.require(:construction).permit(:all, :the, :construction, :attributes, :customer_id)
end

Filter nested model attributes in RoR

I have two models: Users and PaymentMethods, the association between this models is:
class User < ApplicationRecord
has_many :payment_methods, dependent: :destroy
end
class PaymentMethod < ApplicationRecord
belongs_to :user, optional: true
end
I want to loop in each user and see in an attribute of PaymentMethod, named 'period_end_date'. so I do this:
#users = User.all
#users.each do |u|
u.payment_methods.last.period_end_date
end
I'm getting this error => NoMethodError: undefined method `payment_methods' for User::ActiveRecord_Relation
The error is shown because I have 2 test users, in the first user there is still no data in the attribute 'period_end_date' and association exist, but is empty, in the second user there is data in the attributes, if I say, u.payment_methods.last.period_end_date I get => Wed, 13 Jun 2018 (only in the second user)
I want to filter in my loop only the users who has data in PaymentMethod attributes for get rid of => NoMethodError: undefined method `payment_methods' for User::ActiveRecord_Relation
How I do this?
thanks
I want to filter in my loop only the users who has data in PaymentMethod attributes for get rid of => NoMethodError: undefined method `payment_methods' for User::ActiveRecord_Relation
The actual problem seems to be you have users without payment methods (see my comment on your question).
You have some options, depending on how you're going to use the results.
1) You can filter out users without payment methods when you query them from the database like this:
#users = User.joins :payment_methods
2) If #users must include users that without payment methods, you can skip them when looping like this:
#users.map do |user|
next unless user.payment_methods.any?
user.payment_methods.last.period_end_date
end
3) You can guard by checking for payment_methods before calling .last.
User.all.map do |user|
user.payment_methods.last.period_end_date if user.payment_methods.any?
end
4) You can add a period_end_date method to the user
class User < ApplicationRecord
def period_end_date
payment_methods.limit(1).pluck :period_end_date
end
end
5) push #4 into the association by extending it with a helper method
class User < ApplicationRecord
has_many :payment_methods, class_name: 'PaymentMethod' do
def last_period_end_date
last.period_end_date if any?
end
end
end
which you can call like this
User.all.map do |user|
user.payment_methods.last_period_end_date
end
If you're really only concerned about PaymentMethods without a period_end_date then try this:
6) You can still filter users when you query them from the database
#users = User.joins(:payment_methods).where.not(payment_methods: { period_end_date: nil })
7) This can be simplified a bit by pushing the where.not conditions into a scope of the PaymentMethod class:
class PaymentMethod < ApplicationRecord
scope :period_ends, -> { where.not period_end_date: nil }
end
and merging it
#users = User.joins(:payment_methods).merge PaymentMethod.period_ends
Notes
payment_methods.last doesn't specify an order, you should set one (either as part of this chain, when you specify the association, or with a default scope) otherwise the order is up to your database and may be indeterminate.
chain .includes(:payment_methods) to eager load the payment methods and avoid n+1 queries
it sounds like a nil period_end_date could be invalid data. Consider adding a validation / database constraint to prevent this from happening

Using Whenever with Rails 4 - Modify attributes from a model

I got a problem. I use whenever to run daily a method in a model class (of course). This method iterates over a collection of "holidays" instances and then notifies the existence of this holiday to a client (another model).
Here comes the problem; the holidays instances have an attribute called "notified". I use this to know if that holidays was notified to the clients (I don't want to notify a holiday twice). In order to do that, i need to access to the attributes of the holiday instance to chango the value (boolean) of the attribute "notified". But I can not do that (i get no errors, but the attribute doesn't get updated -always false-)
Any hints?
holidays.rb
class Holiday < ActiveRecord::Base
belongs_to :user
def self.notify
#holidays = Holiday.all
#today = DateTime.now.to_date
#holidays.each do |holiday|
if holiday.notified == false && (holiday.fecha - #today).to_i < 15
#clients = holiday.user.clients
#clients.each do |client|
ReminderMailer.new_holiday_reminder(holiday, client).deliver
end
holiday.notified = true <== I need to include something like this
end
end
end
end
And the scheduler.rb
every :day do
runner "Holiday.notify", :environment => 'development'
end
THANKS!!
Use update_attribute. Also, You don't have to use instance variables in your notify method
class Holiday < ActiveRecord::Base
belongs_to :user
def self.notify
holidays = Holiday.all
today = DateTime.now.to_date
holidays.each do |holiday|
if holiday.notified == false && (holiday.fecha - today).to_i < 15
clients = holiday.user.clients
clients.each do |client|
ReminderMailer.new_holiday_reminder(holiday, client).deliver
end
holiday.update_attribute(:notified, true) #or holiday.update_attributes(:notified => true)
end
end
end
end

How to (massively) reduce the number of SQL queries in Rails app?

In my Rails app I have users which can have many invoices which in turn can have many payments.
Now in the dashboard view I want to summarize all the payments a user has ever received, ordered either by year, quarter, or month. The payments are also subdivided into gross, net, and tax.
user.rb:
class User < ActiveRecord::Base
has_many :invoices
has_many :payments
def years
(first_year..current_year).to_a.reverse
end
def year_ranges
years.map { |y| Date.new(y,1,1)..Date.new(y,-1,-1) }
end
def quarter_ranges
...
end
def month_ranges
...
end
def revenue_between(range, kind)
payments_with_invoice ||= payments.includes(:invoice => :items).all
payments_with_invoice.select { |x| range.cover? x.date }.sum(&:"#{kind}_amount")
end
end
invoice.rb:
class Invoice < ActiveRecord::Base
belongs_to :user
has_many :items
has_many :payments
def total
items.sum(&:total)
end
def subtotal
items.sum(&:subtotal)
end
def total_tax
items.sum(&:total_tax)
end
end
payment.rb:
class Payment < ActiveRecord::Base
belongs_to :user
belongs_to :invoice
def percent_of_invoice_total
(100 / (invoice.total / amount.to_d)).abs.round(2)
end
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
end
dashboards_controller:
class DashboardsController < ApplicationController
def index
if %w[year quarter month].include?(params[:by])
range = params[:by]
else
range = "year"
end
#ranges = #user.send("#{range}_ranges")
end
end
index.html.erb:
<% #ranges.each do |range| %>
<%= render :partial => 'range', :object => range %>
<% end %>
_range.html.erb:
<%= #user.revenue_between(range, :gross) %>
<%= #user.revenue_between(range, :taxable) %>
<%= #user.revenue_between(range, :net) %>
Now the problem is that this approach works but produces an awful lot of SQL queries as well. In a typical dashboard view I get 100+ SQL queries. Before adding .includes(:invoice) there were even more queries.
I assume one of the major problems is that each invoice's subtotal, total_tax and total aren't stored anywhere in the database but instead calculated with every request.
Can anybody tell me how to speed up things here? I am not too familiar with SQL and the inner workings of ActiveRecord, so that's probably the problem here.
Thanks for any help.
Whenever revenue_between is called, it fetches the payments in the given time range and the associated invoices and items from the db. Since the time ranges have lot of overlap (month, quarter, year), same records are being fetched over and over again.
I think it is better to fetch all the payments of the user once, then filter and summarize them in Ruby.
To implement, change the revenue_between method as follows:
def revenue_between(range, kind)
#store the all the payments as instance variable to avoid duplicate queries
#payments_with_invoice ||= payments.includes(:invoice => :items).all
#payments_with_invoice.select{|x| range.cover? x.created_at}.sum(&:"#{kind}_amount")
end
This would eager load all the payments along with associated invoices and items.
Also change the invoice summation methods so that it uses the eager loaded items
class Invoice < ActiveRecord::Base
def total
items.map(&:total).sum
end
def subtotal
items.map(&:subtotal).sum
end
def total_tax
items.map(&:total_tax).sum
end
end
Apart from the memoizing strategy proposed by #tihom, I suggest you have a look at the Bullet gem, that as they say in the description, it will help you kill N+1 queries and unused eager loading.
Most of your data do not need to be real time. You can have a service calculating the stats and storing them wherever you want (Redis, cache...). Then refresh them every 10 minutes or upon user's request.
In the first place, render your page without stats and load them with ajax.

Resources