Skip to content

Fix N+1 queries on new case contact view#6843

Open
cliftonmcintosh wants to merge 3 commits intorubyforgood:mainfrom
cliftonmcintosh:fix/6836-n-plus-one-case-contact-datatable
Open

Fix N+1 queries on new case contact view#6843
cliftonmcintosh wants to merge 3 commits intorubyforgood:mainfrom
cliftonmcintosh:fix/6836-n-plus-one-case-contact-datatable

Conversation

@cliftonmcintosh
Copy link
Copy Markdown
Collaborator

@cliftonmcintosh cliftonmcintosh commented Apr 11, 2026

What github issue is this PR for, if any?

Resolves #6836

What changed, and why?

Eliminated four N+1 query sets that fired on every page load of /case_contacts/new_design.

N+1 #1casa_cases per row in the datatable

CaseContactDatatable#data accessed case_contact.casa_case&.case_number for every row. The existing .left_joins(:casa_case) in raw_records joins the table for SQL filtering but does not populate the Ruby association in memory. Each row triggered a separate SELECT … FROM casa_cases WHERE id = ?.

N+1 queries detected:
  SELECT "casa_cases"."id", "casa_cases"."case_number", ... FROM "casa_cases" WHERE "casa_cases"."id" = $1 LIMIT $2
  SELECT "casa_cases"."id", "casa_cases"."case_number", ... FROM "casa_cases" WHERE "casa_cases"."id" = $1 LIMIT $2
  SELECT "casa_cases"."id", "casa_cases"."case_number", ... FROM "casa_cases" WHERE "casa_cases"."id" = $1 LIMIT $2
  ...
Call stack:
  app/datatables/case_contact_datatable.rb:20:in 'block in CaseContactDatatable#data'
  app/datatables/case_contact_datatable.rb:14:in 'Enumerable#map'
  app/datatables/case_contact_datatable.rb:14:in 'CaseContactDatatable#data'
  app/datatables/application_datatable.rb:15:in 'ApplicationDatatable#as_json'
  app/controllers/case_contacts/case_contacts_new_design_controller.rb:15:in 'CaseContacts::CaseContactsNewDesignController#datatable'

Fix: Added :casa_case to the includes chain in CaseContactDatatable#raw_records.

N+1 #2followups.requested.exists? per row in the datatable

:followups was already in the includes, so the records were loaded in memory. However, .requested.exists? always issues a new SQL SELECT 1 … WHERE status = ? LIMIT 1 regardless, because exists? always hits the database.

N+1 queries detected:
  SELECT 1 AS one FROM "followups" WHERE "followups"."case_contact_id" = $1 AND "followups"."status" = $2 LIMIT $3
  SELECT 1 AS one FROM "followups" WHERE "followups"."case_contact_id" = $1 AND "followups"."status" = $2 LIMIT $3
  SELECT 1 AS one FROM "followups" WHERE "followups"."case_contact_id" = $1 AND "followups"."status" = $2 LIMIT $3
  ...
Call stack:
  app/datatables/case_contact_datatable.rb:38:in 'block in CaseContactDatatable#data'
  app/datatables/case_contact_datatable.rb:14:in 'Enumerable#map'
  app/datatables/case_contact_datatable.rb:14:in 'CaseContactDatatable#data'
  app/datatables/application_datatable.rb:15:in 'ApplicationDatatable#as_json'
  app/controllers/case_contacts/case_contacts_new_design_controller.rb:15:in 'CaseContacts::CaseContactsNewDesignController#datatable'

Fix: Replaced with case_contact.followups.any?(&:requested?), which operates on the already-loaded in-memory collection. requested? is generated automatically by enum :status, {requested: 0, resolved: 1} on the Followup model.

N+1 #3casa_orgs per row in the index view

The index view calls policy(case_contact).edit? for each row, which calls same_org?record.casa_org. casa_org is a has_one :through :casa_case on CaseContact. Even though casa_case was already preloaded, the has_one :through association target on the CaseContact object itself was not, so each policy check fired its own SELECT … FROM casa_orgs INNER JOIN casa_cases ….

N+1 queries detected:
  SELECT "casa_orgs".* FROM "casa_orgs" INNER JOIN "casa_cases" ON "casa_orgs"."id" = "casa_cases"."casa_org_id" WHERE "casa_cases"."id" = $1 LIMIT $2
  SELECT "casa_orgs".* FROM "casa_orgs" INNER JOIN "casa_cases" ON "casa_orgs"."id" = "casa_cases"."casa_org_id" WHERE "casa_cases"."id" = $1 LIMIT $2
  SELECT "casa_orgs".* FROM "casa_orgs" INNER JOIN "casa_cases" ON "casa_orgs"."id" = "casa_cases"."casa_org_id" WHERE "casa_cases"."id" = $1 LIMIT $2
  ...
Call stack:
  app/policies/case_contact_policy.rb:61:in 'CaseContactPolicy#same_org?'
  app/policies/application_policy.rb:72:in 'ApplicationPolicy#is_supervisor_same_org?'
  app/policies/application_policy.rb:89:in 'ApplicationPolicy#admin_or_supervisor_same_org?'
  app/policies/case_contact_policy.rb:49:in 'CaseContactPolicy#creator_or_supervisor_or_admin?'
  app/policies/case_contact_policy.rb:11:in 'CaseContactPolicy#update?'
  app/views/case_contacts/case_contacts_new_design/index.html.erb:54
  app/views/case_contacts/case_contacts_new_design/index.html.erb:33:in 'Array#each'

Fix: Added :casa_org directly to the preload chain in LoadsCaseContacts#all_case_contacts.

N+1 #4contact_topics per row in the index view

The index view calls case_contact.contact_topics.map(&:question) for each row. contact_topics is has_many :through :contact_topic_answers. The existing preload contact_topic_answers: :contact_topic loaded topics onto each answer object, but did not populate the contact_topics association target on the CaseContact itself.

N+1 queries detected:
  SELECT "contact_topics".* FROM "contact_topics" INNER JOIN "contact_topic_answers" ON "contact_topics"."id" = "contact_topic_answers"."contact_topic_id" WHERE "contact_topic_answers"."case_contact_id" = $1
  SELECT "contact_topics".* FROM "contact_topics" INNER JOIN "contact_topic_answers" ON "contact_topics"."id" = "contact_topic_answers"."contact_topic_id" WHERE "contact_topic_answers"."case_contact_id" = $1
  SELECT "contact_topics".* FROM "contact_topics" INNER JOIN "contact_topic_answers" ON "contact_topics"."id" = "contact_topic_answers"."contact_topic_id" WHERE "contact_topic_answers"."case_contact_id" = $1
  ...
Call stack:
  app/views/case_contacts/case_contacts_new_design/index.html.erb:79:in 'Enumerable#map'
  app/views/case_contacts/case_contacts_new_design/index.html.erb:79
  app/views/case_contacts/case_contacts_new_design/index.html.erb:33:in 'Array#each'
  app/views/case_contacts/case_contacts_new_design/index.html.erb:33

Fix: Added :contact_topics directly to the preload chain in LoadsCaseContacts#all_case_contacts.

How is this tested? (please write rspec and jest tests!) 💖💪

Manual verification: Reloaded /case_contacts/new_design with the new_case_contact_table Flipper flag enabled and confirmed that none of the four N+1 queries detected warnings appeared in the Rails log.

Possible automated test: A query-count regression test could be added to spec/datatables/case_contact_datatable_spec.rb to ensure the datatable never re-introduces per-row queries for casa_case or followups. The test would use ActiveSupport::Notifications to count SQL events and assert the total stays below a fixed threshold regardless of result set size.

Example regression tests we could add if you want
def count_queries(&block)
  count = 0
  counter = ->(*) { count += 1 }
  ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block)
  count
end

describe "associations loading" do
  let!(:contacts) do
    10.times.map do |i|
      contact = create(:case_contact,
        casa_case: create(:casa_case, casa_org: organization),
        creator: create(:volunteer, casa_org: organization),
        occurred_at: i.days.ago)

      contact_type = create(:contact_type, casa_org: organization)
      contact.contact_types << contact_type

      contact_topic = create(:contact_topic, casa_org: organization)
      create(:contact_topic_answer, case_contact: contact, contact_topic: contact_topic)

      contact
    end
  end

  it "does not fire per-row queries for casa_case" do
    query_count = count_queries { datatable.as_json }
    # With 10 contacts each having a distinct casa_case, an N+1 regression
    # would add 10 extra queries. Fixed bulk queries total ~10.
    # A threshold of 15 catches any per-row querying while tolerating variance
    # in the fixed query set.
    expect(query_count).to be <= 15
  end

  it "does not fire per-row queries for followups" do
    contacts.each { |c| create(:followup, case_contact: c, status: "requested") }

    query_count = count_queries { datatable.as_json }
    # followups are eager-loaded; any?(&:requested?) must not hit the DB per row.
    expect(query_count).to be <= 15
  end
end

AI Usage Disclosure

This PR was developed with the assistance of Claude Code. Claude helped diagnose the N+1 sources, identify the correct preload targets, and draft the fixes. All changes were reviewed and verified manually.

@github-actions github-actions bot added the ruby Pull requests that update Ruby code label Apr 11, 2026
@cliftonmcintosh cliftonmcintosh marked this pull request as ready for review April 11, 2026 17:34
@cliftonmcintosh cliftonmcintosh changed the title Fix/6836 n plus one case contact datatable Fix N+1 queries on new case contact view Apr 11, 2026
@cliftonmcintosh cliftonmcintosh self-assigned this Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ruby Pull requests that update Ruby code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: N+1 queries on new case contacts datatable endpoint

1 participant