Search code examples
ruby-on-railsrubyruby-on-rails-5

Rails 5.2.3 - Show tree of Categories ordered by the total of their associated records


I'm currently using the Ancestry gem to render 500+ categories and sub-categories (they can go up to 3 levels deep).

Now, what I'm trying to do is:

  1. Show only those categories/sub-categories with associated transactions.
  2. Order those categories by:
    • The sum of the :amount of those associated transactions.
    • And also order them by hierarchy.
  3. Print the total next to each category name.

Here's an example of what I'm hoping to achieve:

Travel = $1500
Travel > Air = $1000
Travel > Ground = $250
Business = $500
Business > Services = $250
Business > Services > Marketing = $75
# etc...

This is what my models look like:

class Category < ApplicationRecord
  has_many :transactions
  has_ancestry
end

class Transaction < ApplicationRecord
  belongs_to :account
  belongs_to :category
end

So far I was able to ALMOST get there by doing:

# app/controllers/categories_controller.rb
def index
  # Get all of the root categories
  @primary_categories = Category.where(ancestry: nil)
end

# app/views/categories/index.html.erb
<% @primary_categories.each do |primary_category| %>
  <% primary_category_total = Transaction.where(account_id: current_user, category_id: primary_category.subtree).sum(:amount) %>

  <% if primary_category_total != 0.0 %> 
    <%= link_to primary_category.name, category_path(primary_category) %>
    <%= number_to_currency primary_category_total %>

    <% if primary_category.has_children? && primary_category_total != 0.0 %> 
      <% primary_category.children.each do |secondary_category| %>
        <% secondary_category_total = Transaction.where(account_id: current_user, category_id: primary_category.subtree).sum(:amount) %>

        <% if secondary_category_total != 0.0 %> 
          <%= link_to secondary_category.name, category_path(secondary_category) %>
          <%= number_to_currency secondary_category_total %>

          <% if secondary_category.has_children? && secondary_category_total != 0.0 %>
            <% secondary_category.children.each do |tertiary_category| %>
            <% tertiary_category_total = Transaction.where(account_id: current_user, category_id: primary_category.subtree).sum(:amount) %>

            <% if tertiary_category_total != 0.0 %> 
              <%= link_to tertiary_category.name, category_path(tertiary_category) %>
              <%= number_to_currency tertiary_category.transactions.sum(:amount) %>
# etc...

But this generates an insane amount of queries which it's painfully slow, not to mention that I now have a bunch of complex code on my view. And of course, they are not sorted by total.

How else should I approach this?


Solution

  • After a bit of trial an error (based on Jacob's first answer) I came up with a solution that performs significantly better, removed complexity from the controller and views and does everything on my requirements list from my first post.

    I still think there is room for optimization and clean up but here it goes:

    app/models/transaction.rb

      scope :by_user_category, ->(user, category) do 
        where(account: user.accounts, category: category.subtree)
      end
    

    app/models/category.rb

      def balance(user)
        Transaction.by_user_category(user, self).sum(:amount)
      end
    
      def self.spending_by(user)
        categories_with_spending = []
    
        categories_depth_0 = Category.where(ancestry: nil) # Get the root categories
        categories_depth_0.each do |cat_depth_0|
          category_depth_0_balance = cat_depth_0.balance(user)
    
          if category_depth_0_balance < 0  # "Root category exists and has a balance"
            categories_depth_1_with_spending = []
            categories_depth_1 = Category.find_by_id(cat_depth_0).children # Get the sub-categories
    
            if !categories_depth_1.empty? # "Sub-category exists"
              categories_depth_1.each do |cat_depth_1|
                category_depth_1_balance = cat_depth_1.balance(user)
    
                if category_depth_1_balance < 0 # "Sub-category exists and has a balance"
                  categories_depth_2_with_spending = []
                  categories_depth_2 = Category.find_by_id(cat_depth_1).children
    
                  if !categories_depth_2.empty? # Sub-category has child
                    categories_depth_2.each do |cat_depth_2|
                      category_depth_2_balance = cat_depth_2.balance(user)
    
                      if category_depth_2_balance < 0  # Sub-category child has a balance
                        categories_depth_2_with_spending << {
                          category: cat_depth_2,
                          balance: category_depth_2_balance
                        }
                      end
                    end
                  end
    
                  if categories_depth_2_with_spending != nil
                    # Passing child sub-categories to parent sub-categories
                    categories_depth_1_with_spending << {
                      category: cat_depth_1,
                      balance: category_depth_1_balance,
                      sub_categories: categories_depth_2_with_spending.sort_by { |c| c[:balance] }
                    }
                  end
                end
              end
    
              if categories_depth_1_with_spending != nil
                # Passing sub-categories to root category
                categories_with_spending << {
                  category: cat_depth_0,
                  balance: category_depth_0_balance,
                  sub_categories: categories_depth_1_with_spending.sort_by { |c| c[:balance] }
                }
              end
            else
              # "Root exists but has no sub-categories"
              categories_with_spending << {
                category: cat_depth_0,
                balance: category_depth_0_balance
              }
            end
          end
        end
    
        return categories_with_spending.sort_by { |c| c[:balance] }
      end
    

    app/controllers/categories_controller.rb

      def index
        @categories = Category.spending_by(current_user)
      end
    

    app/views/categories/index.html.erb

      <% @categories.each do |cat_depth_0| %>
        <section class="app-card app-amount-summary app-categories__card">
          <%= render 'category_depth_0', category: cat_depth_0 %>
    
          <% if cat_depth_0[:sub_categories] %>
            <ul class="app-category-list">
              <% cat_depth_0[:sub_categories].each do |cat_depth_1| %>
                <%= render 'category_depth_1', category: cat_depth_1 %>
              <% end %>
            </ul>
          <% end %>
        </section>
      <% end %>
    

    app/views/categories/_category_depth_0.html.erb

    <header class="app-card-header">
      <h3 class="app-card-header__h3">
        <%= link_to category[:category].name, category_path(category[:category].id) %>
      </h3>
      <p class="app-card-header__balance"><%= number_to_currency category[:balance] %></p>
    </header>
    

    _category_depth_1.html.erb works exactly like _category_depth_0.html.erb but with a different structure so I skipped it on this example.