Reputation: 616
I have a "Loan" model in Rails I'm trying to build. There's a corresponding "Payment" model. The balance on the loan is the original amount of the loan minus the sum of all the payments. Calculating the balance is easy, but I'm trying to calculate the balance on lots of loans while avoiding an N+1 query, while at the same time making the "balance" a property of the "Loan" model.
When I call the index method of the Loans controller, I can run a custom select query, which allows me to return a "balance" property through a straight SQL query.
class LoansController < ApplicationController
def index
@loans = Loan
.joins("LEFT JOIN payments on payments.loan_id = loan.id")
.group("loans.id")
.select("loans.*, loans.amount - SUM(payments.amount) as balance")
end
def index_002
@loans = Loan.includes(:payments)
end
def index_003
@loans = Loan.includes(:payments)
end
end
class Loan < ActiveRecord::Base
has_many :payments
def balance=(value)
# I'd like balance to load automatically in the Loan model.
raise NotImplementedError.new("Balance of a loan cannot be set directly.")
end
def balance_002
# No N+1 query, but iterating through each payment in Ruby
# is grossly inefficient as well
amount - payments.map(:amount).inject(0, :+)
end
def balance_003
# Even with the "includes" in the controller, this is N+1
amount - (payments.sum(:amount) || 0)
end
end
Now my question is how to do this all the time with my Loan model. Normally ActiveRecord loads one or more models using the following query:
SELECT * FROM loans
--where clause optional
WHERE id IN (?)
Is there any way to override the Loan model so that it loads the following query:
SELECT
loans.*, loans.amount - SUM(payments.amount) as balance
FROM
loans
LEFT JOIN
payments ON payments.loan_id = loans.id
GROUP BY
loans.id
This way "balance" is a property of the model and only has to be declared in one place, but we also avoid the inefficiencies of N+1 queries.
Upvotes: 1
Views: 2059
Reputation: 616
It looks like I finally answered my own question. Here it is. I overrode the default scope.
class Loan < ActiveRecord::Base
validates :funded_amount, presence: true, numericality: {greater_than: 0}
has_many :payments, dependent: :destroy, inverse_of: :loan
default_scope {
joins("LEFT JOIN payments as p ON p.loan_id = loans.id")
.group("loans.id").select("loans.*, sum(p.amount) as paid")
}
def balance
funded_amount - (paid || 0)
end
end
Upvotes: 0
Reputation: 10004
I like to use database views for this, so that rails thinks it's talking to a regular database table (ensuring things like eager loading work normally) when in fact there are aggregations or complex joins going on. In your case, I might define a second loan_balances
view:
create view loan_balances as (
select loans.id as loan_id, loans.amount - sum(payments.amount) as balance
from loans
left outer join payments on payments.loan_id = loans.id
group by 1
)
Then just do regular rails association stuff:
class LoanBalance < ActiveRecord::Base
belongs_to :loan, inverse_of: :loan_balance
end
class Loan < ActiveRecord::Base
has_one :loan_balance, inverse_of: :loan
delegate :balance, to: :loan_balance, prefix: false
end
This way in actions where you want the balance you can eager load it with includes(:loan_balance)
, but you won't get into thorny issues from violating rails conventions in all the standard CRUD stuff surrounding Loan itself.
Upvotes: 2