From 506f57e7927dd49cccacb5252f9dfdf3c9ecbe49 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 24 Feb 2026 23:12:57 +0000 Subject: [PATCH 1/5] db: add composite index on workshop_sponsors(workshop_id, host) This index optimizes the query that finds the host sponsor for a workshop, improving performance when eager loading the host association. --- .../20260224130000_add_index_workshop_sponsors_host.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260224130000_add_index_workshop_sponsors_host.rb diff --git a/db/migrate/20260224130000_add_index_workshop_sponsors_host.rb b/db/migrate/20260224130000_add_index_workshop_sponsors_host.rb new file mode 100644 index 000000000..47f4f6ac3 --- /dev/null +++ b/db/migrate/20260224130000_add_index_workshop_sponsors_host.rb @@ -0,0 +1,5 @@ +class AddIndexWorkshopSponsorsHost < ActiveRecord::Migration[8.1] + def change + add_index :workshop_sponsors, %i[workshop_id host], name: 'index_workshop_sponsors_on_workshop_id_and_host' + end +end diff --git a/db/schema.rb b/db/schema.rb index 901120493..8cad66b8d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_24_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_02_24_130000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -565,6 +565,7 @@ t.datetime "updated_at", precision: nil t.integer "workshop_id" t.index ["sponsor_id"], name: "index_workshop_sponsors_on_sponsor_id" + t.index ["workshop_id", "host"], name: "index_workshop_sponsors_on_workshop_id_and_host" t.index ["workshop_id"], name: "index_workshop_sponsors_on_workshop_id" end From ddac7533380c650bf508be83278e2dd617db886b Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 24 Feb 2026 23:13:02 +0000 Subject: [PATCH 2/5] models: add eager-loadable host association to Workshop - Add has_one :workshop_host with inverse_of for proper association caching - Add has_one :host through :workshop_host for eager loading - Replace inefficient raw SQL host method with association-based implementation This eliminates N+1 queries when loading workshop hosts on the events index page. --- app/models/workshop.rb | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 39868dcb3..cc81359d1 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -10,6 +10,10 @@ class Workshop < ApplicationRecord has_many :invitations, class_name: 'WorkshopInvitation' has_many :workshop_sponsors has_many :sponsors, through: :workshop_sponsors + has_one :workshop_host, -> { where(workshop_sponsors: { host: true }) }, + class_name: 'WorkshopSponsor', + inverse_of: :workshop + has_one :host, through: :workshop_host, source: :sponsor has_many :organisers, -> { where('permissions.name' => 'organiser') }, through: :permissions, source: :members has_many :feedbacks @@ -31,18 +35,7 @@ class Workshop < ApplicationRecord before_validation :set_opens_at def host - sql = <<~SQL - SELECT sponsors.* - FROM sponsors - LEFT JOIN workshop_sponsors ON workshop_sponsors.sponsor_id = sponsors.id - WHERE workshop_sponsors.workshop_id = ? - AND workshop_sponsors.host = TRUE - AND sponsors.id = workshop_sponsors.sponsor_id - ORDER BY sponsors.updated_at DESC, workshop_sponsors.id ASC - LIMIT 1 - SQL - - Sponsor.find_by_sql([sql, id]).first + workshop_host&.sponsor end def waiting_list From f3ca21762f3fceda2fa9bf44810983969e987a1b Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 24 Feb 2026 23:13:15 +0000 Subject: [PATCH 3/5] models: add inverse_of to WorkshopSponsor belongs_to :workshop This enables proper Rails association caching when accessing workshop from a workshop_sponsor record, reducing redundant queries. --- app/models/workshop_sponsor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/workshop_sponsor.rb b/app/models/workshop_sponsor.rb index 49a49b6a4..36e61dcb9 100644 --- a/app/models/workshop_sponsor.rb +++ b/app/models/workshop_sponsor.rb @@ -1,6 +1,6 @@ class WorkshopSponsor < ApplicationRecord belongs_to :sponsor - belongs_to :workshop + belongs_to :workshop, inverse_of: :workshop_sponsors validates :sponsor_id, uniqueness: { scope: :workshop_id, message: :already_sponsoring } end From 6edf01c63247179fc50da9343517de7d3e679efe Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 24 Feb 2026 23:13:21 +0000 Subject: [PATCH 4/5] controllers: add eager loading includes to EventsController#index Add :host, :permissions, and :sponsorships to includes for Workshop, Meeting, and Event queries. This eliminates N+1 queries when rendering the events index page by loading all associations in a single query. --- app/controllers/events_controller.rb | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 8b3007984..1dd59079b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -8,19 +8,25 @@ class EventsController < ApplicationController def index fresh_when(latest_model_updated, etag: latest_model_updated) - events = [Workshop.past.includes(:chapter, - :sponsors).joins(:chapter).merge(Chapter.active).limit(RECENT_EVENTS_DISPLAY_LIMIT)] - events << Meeting.past.includes(:venue).limit(RECENT_EVENTS_DISPLAY_LIMIT) - events << Event.past.includes(:venue, :sponsors, :sponsorships).limit(RECENT_EVENTS_DISPLAY_LIMIT) + events = [Workshop.past + .includes(:chapter, :sponsors, :host, :permissions) + .joins(:chapter) + .merge(Chapter.active) + .limit(RECENT_EVENTS_DISPLAY_LIMIT)] + events << Meeting.past.includes(:venue, :permissions).limit(RECENT_EVENTS_DISPLAY_LIMIT) + events << Event.past.includes(:venue, :sponsors, :sponsorships, :permissions).limit(RECENT_EVENTS_DISPLAY_LIMIT) events = events.compact.flatten.sort_by(&:date_and_time).reverse.first(RECENT_EVENTS_DISPLAY_LIMIT) events_hash_grouped_by_date = events.group_by(&:date) @past_events = events_hash_grouped_by_date.map.each_with_object({}) do |(key, value), hash| hash[key] = EventPresenter.decorate_collection(value) end - events = [Workshop.includes(:chapter, :sponsors).upcoming.joins(:chapter).merge(Chapter.active)] - events << Meeting.upcoming.all - events << Event.upcoming.includes(:venue, :sponsors, :sponsorships).all + events = [Workshop.upcoming + .includes(:chapter, :sponsors, :host, :permissions) + .joins(:chapter) + .merge(Chapter.active)] + events << Meeting.upcoming.includes(:venue, :permissions).all + events << Event.upcoming.includes(:venue, :sponsors, :sponsorships, :permissions).all events = events.compact.flatten.sort_by(&:date_and_time).group_by(&:date) @events = events.map.each_with_object({}) do |(key, value), hash| hash[key] = EventPresenter.decorate_collection(value) From f923e9437e42226046b2acaf93a5df1d7bdd6d1b Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 24 Feb 2026 23:13:27 +0000 Subject: [PATCH 5/5] presenters: add memoization to WorkshopPresenter#venue Use @venue ||= to cache the host sponsor, preventing redundant calls to model.host which could trigger additional queries in certain contexts. --- app/presenters/workshop_presenter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/presenters/workshop_presenter.rb b/app/presenters/workshop_presenter.rb index c9292a726..84fecaa6d 100644 --- a/app/presenters/workshop_presenter.rb +++ b/app/presenters/workshop_presenter.rb @@ -28,7 +28,7 @@ def attending_and_available_coach_spots end def venue - model.host + @venue ||= model.host end def organisers