0

In DB there ara many accounts, every account has lots of transactions with price attribute. It's needed to count account balance by summarize all transaction prices.

class AccountsController < ApplicationController
  def index
    @accounts = Account.all
  end
end

Account model:

class Account < ActiveRecord::Base
  has_many :transactions, dependent: :destroy
  includes :transactions

  def balance
    transactions.sum(:price) || 0
  end

  def balance_ruby_sum
    sum = 0
    transactions.each { |t| sum+= t.price }
    sum
  end
end

Transaction model:

class Transaction < ActiveRecord::Base
  belongs_to :account
end

account index.html.erb:

<table>
    <th>name</th>
    <th>balance</th>
    <% @accounts.each do |acc| %>
        <td><%= acc.name %></td>
        <td><%= acc.balance %></td>
    <% end %>
</table>

In index view I have account's name and its balance. It's needed to fire N+1 queries to DB to do this and I want to avoid N+1 problem. I tried to use balance_ruby_sum action instead of balance action (only 2 queries are needed), but it's not a good solution (performance issue).

I don't want to retrieve all full transaction, only their sum of prices. Is it possible to count balance by sum prices using SQL and do only 2 queries?

I found an article there in 4 item described how to use a query object. But I don't understand how to create a one in my case.

wiseland
  • 1,010
  • 9
  • 16
  • Your `balance` method is correct. How do you call it that it causes N+1? – BroiSatse Aug 24 '14 at 23:55
  • For ex. if I have 10 accounts I need to fire 11 queries to get accounts and their balances and only 2 queries if I use `balance_ruby_sum`. Is it possible to get balance by summarize prices in 2 queries? – wiseland Aug 25 '14 at 00:07

1 Answers1

4

Try to play with select like this and you should have what you want in a single query.

@accounts = Account.all.joins(:transactions).
  select("accounts.*, SUM(transactions.price) AS balance").
  group("accounts.id")

The only problem with this query is that you will only get the accounts that actually have transactions. Those without transactions will be left out.

What's happening

The first part Account.all.joins(:transactions) joins the accounts and transactions table together. ActiveRecord is clever enough to know that we're using account_id on the transactions to join the tables.

Next, what we want on this join table is the account information, but we'd like an "extra field" that is the sum of all that accounts transactions prices. We basically want:

+-------------+-----+-----+---------+
|  account id | ... | ... | balance |
+-------------+-----+-----+---------+

That's why we need the select statement select("accounts.*, SUM(transactions.price) AS balance"). We're telling ActiveRecord to give us, in this join table, all the account attributes and also to build a special one, balance, that is the sum of all transaction prices associated to this account.

Finally, we need to give ActiveRecord a clue on how to organize the results and we tell it to group the records by account id. Which in our case means one account per line.

balance_ruby_sum

If you want to keep your balance_ruby_sum method, you might wanna consider using inject like this:

def balance_ruby_sum
  transactions.inject(0) { |sum, t| sum + t.price }
end

You won't gain performance but it's more rubyesque :)

Edit return 0 when no transaction as requested in comments:

To make sure we get one line per Account even if there is no transaction attached. We need to force a LEFT OUTER JOIN instead of an INNER JOIN.

Now SUM, when there is no transaction, will return NULL as balance and we want it to return 0. The way to return 0 when there are no transactions is to use the COALESCE keyword around SUM. COALESCE is used to return a default value in case the first parameter is NULL. Your request now looks like this:

@accounts = Account.all.
  joins("LEFT OUTER JOIN transactions ON accounts.id = transactions.account_id").
  select("accounts.*, COALESCE(SUM(transactions.price), 0) as balance").
  group("accounts.id")

You can read more about INNER JOIN and OUTER JOIN here: What is the difference between "INNER JOIN" and "OUTER JOIN"?

Community
  • 1
  • 1
Marc Lainez
  • 3,070
  • 11
  • 16
  • Yes, thanks. It's that I need. Btw: what could you suggest if I need balance (0 or "no transactions") for accounts which don't have transactions? Fire a separate query? Could I use eager_load(:transactions) or not? I'm not sure that my variant to use default "no transactions" for those accounts is pretty enough. – wiseland Aug 27 '14 at 16:02
  • Edited my answer with a possible one query solution to return 0 when no transactions. Please try it out and let me know if it works. – Marc Lainez Aug 27 '14 at 16:34
  • Thanks, it works. The only issue is that in case using 'LEFT JOIN' there is no SQL select optimization by ref. When I do `explain` for query, I see that it scans all rows in account and transaction tables. – wiseland Aug 27 '14 at 21:08
  • Maybe you can optimise that with the right index. Make sure there is an index on transactions.account_id. That should do the trick. You should already have one on accounts.id – Marc Lainez Aug 27 '14 at 21:14