Skip to content

Commit 33d28ac

Browse files
committed
feat(platforms): optimize platform memberships loading to prevent N+1 queries
feat(image_helper): enhance profile image handling with optimized URL method feat(person): add non-blocking profile image variant and URL methods test(image_helper): add specs for profile_image_tag method test(performance): add performance tests for profile image loading
1 parent 0d7c254 commit 33d28ac

File tree

7 files changed

+203
-26
lines changed

7 files changed

+203
-26
lines changed

app/controllers/better_together/platforms_controller.rb

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ def index
1717
# GET /platforms/1
1818
def show
1919
authorize @platform
20+
# Preload memberships with policy scope applied to prevent N+1 queries in view
21+
# Include comprehensive associations for members and roles to eliminate N+1 queries
22+
@platform_memberships = policy_scope(@platform.memberships_with_associations)
2023
end
2124

2225
# GET /platforms/new
@@ -124,25 +127,45 @@ def resource_class
124127
end
125128

126129
def resource_collection
127-
# Comprehensive eager loading to prevent N+1 queries for platform memberships
128-
# This loads all necessary associations including:
129-
# - Mobility translations (string & text)
130-
# - Active Storage attachments with blobs and variants
131-
# - Platform memberships with member/role associations
132-
# Note: Platform invitations are now loaded separately via lazy Turbo frames
133-
resource_class.with_translations.includes(
134-
# Cover and profile image attachments with blobs and variants
130+
# Comprehensive eager loading to prevent N+1 queries across all platform associations
131+
resource_class.includes(
132+
# Platform's own translations and attachments
133+
:string_translations,
134+
:text_translations,
135135
cover_image_attachment: { blob: :variant_records },
136136
profile_image_attachment: { blob: :variant_records },
137-
# Person platform memberships with comprehensive associations
138-
person_platform_memberships: [
139-
{ member: [
140-
:string_translations,
141-
{ profile_image_attachment: { blob: :variant_records } }
142-
] },
143-
{ role: %i[
137+
138+
# Community association with its own attachments
139+
community: [
140+
:string_translations,
141+
:text_translations,
142+
{ profile_image_attachment: { blob: :variant_records } },
143+
{ cover_image_attachment: { blob: :variant_records } }
144+
],
145+
146+
# Content blocks
147+
platform_blocks: {
148+
block: %i[
144149
string_translations
145-
] }
150+
text_translations
151+
]
152+
},
153+
154+
# Person platform memberships with all necessary nested associations
155+
person_platform_memberships: [
156+
{
157+
member: [
158+
:string_translations,
159+
:text_translations,
160+
{ profile_image_attachment: { blob: :variant_records } }
161+
]
162+
},
163+
{
164+
role: %i[
165+
string_translations
166+
text_translations
167+
]
168+
}
146169
]
147170
)
148171
end

app/helpers/better_together/image_helper.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,17 @@ def profile_image_tag(entity, options = {}) # rubocop:todo Metrics/MethodLength,
119119

120120
# Determine if entity has a profile image
121121
if entity.respond_to?(:profile_image) && entity.profile_image.attached?
122-
attachment = if entity.respond_to?(:optimized_profile_image)
123-
entity.optimized_profile_image
124-
else
125-
entity.profile_image_variant(image_size)
126-
end
127-
128-
image_tag(rails_storage_proxy_url(attachment), **image_tag_attributes)
122+
# Use optimized URL method that doesn't block on .processed
123+
image_url = if entity.respond_to?(:profile_image_url)
124+
entity.profile_image_url(size: image_size)
125+
elsif entity.respond_to?(:optimized_profile_image)
126+
rails_storage_proxy_url(entity.optimized_profile_image)
127+
else
128+
# Fallback to variant without calling .processed
129+
rails_storage_proxy_url(entity.profile_image_variant(image_size))
130+
end
131+
132+
image_tag(image_url, **image_tag_attributes) if image_url
129133
else
130134
# Use a default image based on the entity type
131135
default_image = default_profile_image(entity, image_format)

app/models/better_together/person.rb

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,24 @@ def email
112112
has_one_attached :profile_image
113113
has_one_attached :cover_image
114114

115-
# Resize the profile image before rendering
115+
# Resize the profile image before rendering (non-blocking version)
116116
def profile_image_variant(size)
117-
profile_image.variant(resize_to_fill: [size, size]).processed
117+
return profile_image.variant(resize_to_fill: [size, size]) unless Rails.env.production?
118+
119+
# In production, avoid blocking .processed calls
120+
profile_image.variant(resize_to_fill: [size, size])
121+
end
122+
123+
# Get optimized profile image variant without blocking rendering
124+
def profile_image_url(size: 300)
125+
return nil unless profile_image.attached?
126+
127+
variant = profile_image.variant(resize_to_fill: [size, size])
128+
129+
# For better performance, use Rails URL helpers for variant
130+
Rails.application.routes.url_helpers.url_for(variant)
131+
rescue ActiveStorage::FileNotFoundError
132+
nil
118133
end
119134

120135
# Resize the cover image to specific dimensions

app/models/better_together/platform.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ def primary_community_extra_attrs
8080
{ host:, protected: }
8181
end
8282

83+
# Efficiently load platform memberships with all necessary associations
84+
# to prevent N+1 queries in views
85+
def memberships_with_associations
86+
person_platform_memberships.includes(
87+
{
88+
member: [
89+
:string_translations,
90+
:text_translations,
91+
{ profile_image_attachment: { blob: { variant_records: [], preview_image_attachment: { blob: [] } } } }
92+
]
93+
},
94+
{
95+
role: %i[
96+
string_translations
97+
text_translations
98+
]
99+
}
100+
)
101+
end
102+
83103
def to_s
84104
name
85105
end

app/views/better_together/platforms/show.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989

9090
<!-- Platform Members Section -->
9191
<section id="members" class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 collapse" aria-labelledby="members-tab" aria-expanded="false" data-bs-parent="#platformTabs">
92-
<%= render partial: 'better_together/person_platform_memberships/person_platform_membership_member', collection: policy_scope(@platform.person_platform_memberships), as: :person_platform_membership %>
92+
<%= render partial: 'better_together/person_platform_memberships/person_platform_membership_member', collection: @platform_memberships, as: :person_platform_membership %>
9393
</section>
9494

9595
<% if policy(BetterTogether::PlatformInvitation).index? %>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe BetterTogether::ImageHelper, type: :helper do
4+
include BetterTogether::ImageHelper
5+
6+
describe '#profile_image_tag' do
7+
let(:person) { create(:better_together_person) }
8+
9+
context 'when person has no profile image' do
10+
it 'returns a default image tag' do
11+
result = profile_image_tag(person)
12+
expect(result).to include('class="profile-image rounded-circle')
13+
expect(result).to include('alt="Profile Image"')
14+
end
15+
end
16+
17+
context 'when person has profile_image_url method' do
18+
before do
19+
allow(person).to receive(:respond_to?).and_return(false)
20+
allow(person).to receive(:respond_to?).with(:profile_image).and_return(true)
21+
allow(person).to receive(:respond_to?).with(:profile_image, anything).and_return(true)
22+
allow(person).to receive(:respond_to?).with(:profile_image_url).and_return(true)
23+
allow(person).to receive(:respond_to?).with(:profile_image_url, anything).and_return(true)
24+
allow(person).to receive(:profile_image_url).and_return('http://example.com/optimized.jpg')
25+
26+
# Mock profile_image.attached? to return true
27+
profile_image_double = double('profile_image', attached?: true)
28+
allow(person).to receive(:profile_image).and_return(profile_image_double)
29+
end
30+
31+
it 'uses the optimized profile_image_url method' do
32+
expect(person).to receive(:profile_image_url).with(size: 300)
33+
result = profile_image_tag(person)
34+
expect(result).to include('src="http://example.com/optimized.jpg"')
35+
expect(result).to include('class="profile-image rounded-circle')
36+
end
37+
38+
it 'respects custom size parameter' do
39+
expect(person).to receive(:profile_image_url).with(size: 150)
40+
profile_image_tag(person, size: 150)
41+
end
42+
end
43+
end
44+
end
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe 'Profile Image Performance', type: :request do
4+
include ActiveSupport::Testing::TimeHelpers
5+
6+
before do
7+
configure_host_platform
8+
end
9+
10+
context 'with multiple platform members' do
11+
let!(:platform) { create(:better_together_platform) }
12+
let!(:people) { create_list(:better_together_person, 3) } # Reduce to 3 for faster test
13+
let!(:role) { create(:better_together_role, :platform_role) } # Create platform role
14+
let!(:memberships) do
15+
people.map do |person|
16+
create(:better_together_person_platform_membership,
17+
joinable: platform,
18+
member: person,
19+
role: role) # Reuse the same platform role
20+
end
21+
end
22+
23+
it 'loads platform show page efficiently with profile images' do
24+
# Measure execution time
25+
start_time = Time.current
26+
27+
# Make request to platform show page
28+
get better_together.platform_path(platform, locale: I18n.default_locale)
29+
30+
end_time = Time.current
31+
execution_time = end_time - start_time
32+
33+
# Verify response is successful
34+
expect(response).to have_http_status(:success)
35+
36+
# Log performance metrics (this will help track improvements)
37+
Rails.logger.info "Platform show page with #{people.count} members loaded in #{execution_time} seconds"
38+
39+
# Performance expectation - should load in under 5 seconds
40+
expect(execution_time).to be < 5.seconds
41+
42+
# Verify content includes member count (proving the associations loaded)
43+
expect(response.body).to include('membership-column')
44+
45+
# Count the membership cards rendered
46+
membership_count = response.body.scan('membership-column').length
47+
expect(membership_count).to eq(people.count)
48+
end
49+
end
50+
51+
context 'profile_image_url method performance' do
52+
let(:person) { create(:better_together_person) }
53+
54+
it 'calls profile_image_url without errors when no image is attached' do
55+
start_time = Time.current
56+
57+
# Call our optimized method (should return nil gracefully)
58+
result = person.profile_image_url(size: 150)
59+
60+
end_time = Time.current
61+
execution_time = end_time - start_time
62+
63+
expect(result).to be_nil
64+
65+
# Should be reasonably fast (under 2 seconds for no image)
66+
expect(execution_time).to be < 2.seconds
67+
68+
Rails.logger.info "profile_image_url method executed in #{execution_time} seconds"
69+
end
70+
end
71+
end

0 commit comments

Comments
 (0)