perf(reports): avoid residual category lazy loads (#2255)

* perf(reports): avoid residual category lazy loads

* test(reports): reuse SQL query capture helper

Move the reports SQL capture regression helper into test/support and document the category loading and budget reload choices called out in review.
This commit is contained in:
ghost
2026-06-18 11:19:36 -07:00
committed by GitHub
parent 894d705e83
commit 6ae9779567
4 changed files with 51 additions and 3 deletions

View File

@@ -90,7 +90,9 @@ class Budget < ApplicationRecord
end
def sync_budget_categories
current_category_ids = family.categories.pluck(:id).to_set
# Category changes can leave the association memoized before this sync runs.
current_categories_by_id = family.categories.reload.index_by(&:id)
current_category_ids = current_categories_by_id.keys.to_set
existing_budget_category_ids = budget_categories.pluck(:category_id).to_set
categories_to_add = current_category_ids - existing_budget_category_ids
categories_to_remove = existing_budget_category_ids - current_category_ids
@@ -98,7 +100,7 @@ class Budget < ApplicationRecord
# Create missing categories
categories_to_add.each do |category_id|
budget_categories.create!(
category_id: category_id,
category: current_categories_by_id.fetch(category_id),
budgeted_spending: 0,
currency: family.currency
)

View File

@@ -134,7 +134,8 @@ class IncomeStatement
NetCategoryTotals = Data.define(:net_expense_categories, :net_income_categories, :total_net_expense, :total_net_income, :currency)
def categories
@categories ||= family.categories.all.to_a
# Keep Category#subcategory?'s parent-based orphan semantics without lazy loads.
@categories ||= family.categories.includes(:parent).to_a
end
def period_cache_key(period)

View File

@@ -193,6 +193,40 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
assert_select ".text-center.py-8.text-subdued", { text: /No spending data/, count: 0 }, "Should not show 'No spending data' message when transactions exist"
end
test "index avoids residual category lazy loads" do
account = accounts(:depository)
4.times do |idx|
parent = @family.categories.create!(
name: "Reports Parent #{idx}",
color: "#000000",
lucide_icon: "folder"
)
child = @family.categories.create!(
name: "Reports Child #{idx}",
color: "#111111",
lucide_icon: "folder",
parent: parent
)
entry = account.entries.create!(
name: "Reports transaction #{idx}",
date: Date.current,
amount: 10 + idx,
currency: "USD",
entryable: Transaction.new(
category: child,
kind: "standard"
)
)
assert entry.persisted?
end
queries = capture_sql_queries { get reports_path(period_type: :monthly) }
assert_response :ok
assert_empty queries.grep(/SELECT "categories"\.\* FROM "categories" WHERE "categories"\."id" = \$1 LIMIT \$2/)
end
test "export transactions with API key authentication" do
# Use an active API key with read permissions
api_key = api_keys(:active_key)

View File

@@ -39,6 +39,17 @@ class IncomeStatementTest < ActiveSupport::TestCase
assert_equal expected_total_expense, expense_totals.category_totals.find { |ct| ct.category.id == @food_category.id }.total
end
test "orphaned categories still count as root category totals" do
orphan_category = @family.categories.create! name: "Orphaned Category"
orphan_category.update_column(:parent_id, SecureRandom.uuid)
create_transaction(account: @checking_account, amount: 123, category: orphan_category)
expense_totals = IncomeStatement.new(@family).expense_totals(period: Period.last_30_days)
assert_equal 200 + 300 + 400 + 123, expense_totals.total
assert_equal 123, expense_totals.category_totals.find { |ct| ct.category.id == orphan_category.id }.total
end
test "memoizes expense and income period totals across repeated calculations" do
income_statement = IncomeStatement.new(@family)
period = Period.last_30_days