Skip to content

Commit a40a32d

Browse files
authored
Optimizations to show page (forem#22751)
* Optimize article queries in a few places * Adjust optimizations * Adjust comment
1 parent 0916866 commit a40a32d

File tree

6 files changed

+252
-9
lines changed

6 files changed

+252
-9
lines changed

app/controllers/stories_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,8 @@ def assign_article_show_variables
345345
# considering non cross posted articles with a more recent publication date
346346
@collection_articles = @article.collection.articles
347347
.published.from_subforem
348-
.order(Arel.sql("COALESCE(crossposted_at, published_at) ASC"))
348+
.select(:id, :path, :title, :slug, :published_at, :crossposted_at, :user_id, :organization_id, :cached_tag_list, :subforem_id, :main_image)
349+
.order(Arel.sql("COALESCE(articles.crossposted_at, articles.published_at) ASC"))
349350
end
350351

351352
@comments_to_show_count = @article.cached_tag_list_array.include?("discuss") ? 50 : 30

app/services/articles/get_user_stickies.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
module Articles
22
class GetUserStickies
33
def self.call(article, author)
4-
article_tags = article.cached_tag_list_array - ["discuss"]
4+
article_tags = article.cached_tag_list.to_s.split(", ") - ["discuss"]
55

66
author
77
.articles
88
.published.from_subforem
99
.cached_tagged_with_any(article_tags)
1010
.unscope(:select)
11-
.minimal_feed_column_select
11+
.select(:id, :path, :title, :cached_tag_list, :organization_id, :user_id, :subforem_id) # Columns needed for _sticky_nav and path generation
1212
.where.not(id: article.id)
1313
.order(published_at: :desc)
1414
.limit(3)

app/services/articles/suggest_stickies.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def call
4040
attr_accessor :article, :reaction_count_num, :comment_count_num
4141

4242
def tag_articles
43-
article_tags = article.cached_tag_list_array - ["discuss"]
43+
article_tags = article.cached_tag_list.to_s.split(", ") - ["discuss"]
4444

4545
scope = Article
4646
.where("public_reactions_count > ? OR comments_count > ?", reaction_count_num, comment_count_num)
@@ -68,7 +68,7 @@ def apply_common_scope(scope:, tags:)
6868
scope.published.from_subforem
6969
.cached_tagged_with_any(tags)
7070
.unscope(:select)
71-
.minimal_feed_column_select
71+
.select(:id, :path, :title, :cached_tag_list, :cached_user, :organization_id, :user_id) # Columns needed for _sticky_nav (includes cached_user for avatar)
7272
.where.not(id: article.id)
7373
.not_authored_by(article.user_id)
7474
.where("published_at > ?", 5.days.ago)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
require "rails_helper"
2+
3+
RSpec.describe "StoriesPerformanceFix", type: :request do
4+
let(:user) { create(:user) }
5+
6+
describe "GET /:username/:slug (article show with collection)" do
7+
let(:collection) { create(:collection, user: user) }
8+
let(:article) do
9+
create(:article, user: user, published: true, collection: collection,
10+
cached_tag_list: "ruby, rails", main_image: "https://example.com/image.png")
11+
end
12+
let!(:other_collection_articles) do
13+
create_list(:article, 3, user: user, published: true, collection: collection,
14+
cached_tag_list: "ruby", main_image: "https://example.com/other.png")
15+
end
16+
17+
before do
18+
# Create articles to trigger sticky nav suggestions
19+
create_list(:article, 3, user: user, published: true, cached_tag_list: "ruby")
20+
career_articles = create_list(:article, 3, published: true, cached_tag_list: "career",
21+
public_reactions_count: 50)
22+
career_articles.each { |a| a.update_columns(published_at: 1.day.ago) }
23+
end
24+
25+
it "renders the article show page with collection without MissingAttributeError" do
26+
# The collection partial uses: id, path, title, slug, published_at, crossposted_at,
27+
# user_id, organization_id, cached_tag_list, subforem_id, main_image
28+
get article.path
29+
follow_redirect! if response.status == 301
30+
expect(response).to have_http_status(:ok)
31+
end
32+
33+
it "renders sticky nav with user stickies (GetUserStickies)" do
34+
# GetUserStickies uses: id, path, title, cached_tag_list, organization_id, user_id, subforem_id
35+
get article.path
36+
follow_redirect! if response.status == 301
37+
expect(response).to have_http_status(:ok)
38+
end
39+
40+
it "renders sticky nav with trending articles (SuggestStickies) when no user stickies" do
41+
# Remove all same-author articles so GetUserStickies returns empty
42+
user.articles.where.not(id: article.id).destroy_all
43+
# SuggestStickies uses: id, path, title, cached_tag_list, cached_user, organization_id, user_id
44+
get article.path
45+
follow_redirect! if response.status == 301
46+
expect(response).to have_http_status(:ok)
47+
end
48+
end
49+
50+
describe "GET /:username (user profile with articles)" do
51+
let!(:articles) do
52+
create_list(:article, 3, user: user, published: true, cached_tag_list: "ruby",
53+
main_image: "https://example.com/image.png")
54+
end
55+
56+
it "renders user profile page without MissingAttributeError" do
57+
# limited_column_select provides: path, title, id, published, comments_count,
58+
# public_reactions_count, cached_tag_list, main_image, main_image_background_hex_color,
59+
# updated_at, slug, video, user_id, organization_id, video_source_url, video_code,
60+
# video_thumbnail_url, video_closed_caption_track_url, cached_user, cached_organization,
61+
# published_at, crossposted_at, description, reading_time, video_duration_in_seconds,
62+
# score, last_comment_at, main_image_height, type_of, edited_at, processed_html, subforem_id
63+
get "/#{user.username}"
64+
expect(response).to have_http_status(:ok)
65+
end
66+
67+
it "renders all article titles on the profile page" do
68+
get "/#{user.username}"
69+
expect(response).to have_http_status(:ok)
70+
articles.each do |a|
71+
expect(response.body).to include(a.title)
72+
end
73+
end
74+
75+
it "renders article reading time without MissingAttributeError" do
76+
get "/#{user.username}"
77+
expect(response).to have_http_status(:ok)
78+
expect(response.body).to include("min read")
79+
end
80+
81+
context "with pinned articles" do
82+
let!(:pin) { create(:profile_pin, profile: user, pinnable: articles.first) }
83+
84+
it "renders pinned articles without MissingAttributeError" do
85+
get "/#{user.username}"
86+
expect(response).to have_http_status(:ok)
87+
expect(response.body).to include("Pinned")
88+
expect(response.body).to include(articles.first.title)
89+
end
90+
end
91+
end
92+
93+
describe "GET /:org_slug (organization profile with articles)" do
94+
let(:org) { create(:organization) }
95+
let!(:org_membership) { create(:organization_membership, user: user, organization: org) }
96+
let!(:org_articles) do
97+
create_list(:article, 3, organization: org, user: user, published: true,
98+
cached_tag_list: "devops", main_image: "https://example.com/org.png")
99+
end
100+
101+
it "renders organization profile page without MissingAttributeError" do
102+
get "/#{org.slug}"
103+
expect(response).to have_http_status(:ok)
104+
end
105+
106+
it "renders org article cards with all required columns" do
107+
get "/#{org.slug}"
108+
expect(response).to have_http_status(:ok)
109+
org_articles.each do |a|
110+
expect(response.body).to include(a.title)
111+
end
112+
end
113+
end
114+
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
require "rails_helper"
2+
3+
RSpec.describe Articles::GetUserStickies, type: :service do
4+
let(:user) { create(:user) }
5+
let(:article) { create(:article, user: user, published: true, cached_tag_list: "ruby") }
6+
let!(:other_articles) { create_list(:article, 3, user: user, published: true, cached_tag_list: "ruby") }
7+
8+
describe ".call" do
9+
it "returns articles with limited attributes needed by _sticky_nav" do
10+
result = described_class.call(article, user)
11+
expect(result).not_to be_empty
12+
13+
sticky = result.first
14+
# Columns explicitly selected for _sticky_nav partial:
15+
expect { sticky.id }.not_to raise_error
16+
expect { sticky.path }.not_to raise_error
17+
expect { sticky.title }.not_to raise_error
18+
expect { sticky.cached_tag_list }.not_to raise_error
19+
expect { sticky.organization_id }.not_to raise_error
20+
expect { sticky.user_id }.not_to raise_error
21+
expect { sticky.subforem_id }.not_to raise_error
22+
end
23+
24+
it "allows decorating for cached_tag_list_array (used in view)" do
25+
result = described_class.call(article, user)
26+
sticky = result.first
27+
# _sticky_nav calls article.decorate.cached_tag_list_array
28+
expect { sticky.decorate.cached_tag_list_array }.not_to raise_error
29+
expect(sticky.decorate.cached_tag_list_array).to be_a(Array)
30+
end
31+
32+
it "excludes the current article" do
33+
result = described_class.call(article, user)
34+
expect(result.map(&:id)).not_to include(article.id)
35+
end
36+
37+
it "does not load unnecessary columns" do
38+
result = described_class.call(article, user)
39+
sticky = result.first
40+
expect { sticky.body_markdown }.to raise_error(ActiveModel::MissingAttributeError)
41+
expect { sticky.processed_html }.to raise_error(ActiveModel::MissingAttributeError)
42+
end
43+
44+
it "handles article with nil cached_tag_list" do
45+
article.update_column(:cached_tag_list, nil)
46+
result = described_class.call(article.reload, user)
47+
# Should not raise, just returns empty or unfiltered results
48+
expect(result).to be_a(ActiveRecord::Relation)
49+
end
50+
end
51+
end
Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,88 @@
11
require "rails_helper"
22

3-
RSpec.describe Articles::SuggestStickies, type: :services do
4-
let(:article) { create(:article).decorate }
3+
RSpec.describe Articles::SuggestStickies, type: :service do
4+
let(:user) { create(:user) }
5+
let(:article) { create(:article, user: user, published: true, cached_tag_list: "ruby") }
6+
7+
# Create articles with SUGGESTION_TAGS that meet the reaction/comment thresholds.
8+
# We create first, then update published_at via update_column to bypass validation.
9+
let!(:career_articles) do
10+
articles = create_list(:article, 3, published: true, cached_tag_list: "career",
11+
public_reactions_count: 50, comments_count: 10)
12+
articles.each { |a| a.update_columns(published_at: 1.day.ago) }
13+
articles
14+
end
15+
let!(:discuss_articles) do
16+
articles = create_list(:article, 2, published: true, cached_tag_list: "discuss",
17+
public_reactions_count: 50, comments_count: 10)
18+
articles.each { |a| a.update_columns(published_at: 1.day.ago) }
19+
articles
20+
end
521

622
describe ".call" do
7-
subject(:method_call) { described_class.call(article) }
23+
it "returns articles with limited attributes needed by _sticky_nav" do
24+
result = described_class.call(article)
25+
expect(result).not_to be_empty
26+
27+
sticky = result.first
28+
# Columns explicitly selected for _sticky_nav (suggest stickies section):
29+
expect { sticky.id }.not_to raise_error
30+
expect { sticky.path }.not_to raise_error
31+
expect { sticky.title }.not_to raise_error
32+
expect { sticky.cached_tag_list }.not_to raise_error
33+
expect { sticky.cached_user }.not_to raise_error
34+
expect { sticky.organization_id }.not_to raise_error
35+
expect { sticky.user_id }.not_to raise_error
36+
end
37+
38+
it "allows accessing cached_user avatar data (used in view)" do
39+
result = described_class.call(article)
40+
expect(result).not_to be_empty
41+
42+
sticky = result.first
43+
# _sticky_nav calls article.cached_user.profile_image_url_for and cached_user.name
44+
cached_user = sticky.cached_user
45+
expect(cached_user).not_to be_nil
46+
expect { cached_user.name }.not_to raise_error
47+
end
48+
49+
it "allows decorating for cached_tag_list_array (used in view)" do
50+
result = described_class.call(article)
51+
expect(result).not_to be_empty
52+
53+
sticky = result.first
54+
# _sticky_nav calls article.decorate.cached_tag_list_array
55+
expect { sticky.decorate.cached_tag_list_array }.not_to raise_error
56+
end
57+
58+
it "does not include the current article" do
59+
result = described_class.call(article)
60+
expect(result.map(&:id)).not_to include(article.id)
61+
end
62+
63+
it "does not include articles by the same author" do
64+
result = described_class.call(article)
65+
expect(result.map(&:user_id)).not_to include(article.user_id)
66+
end
67+
68+
it "does not load unnecessary columns (proves optimization)" do
69+
result = described_class.call(article)
70+
next if result.empty?
71+
72+
sticky = result.first
73+
expect { sticky.body_markdown }.to raise_error(ActiveModel::MissingAttributeError)
74+
expect { sticky.processed_html }.to raise_error(ActiveModel::MissingAttributeError)
75+
end
76+
77+
it "returns an array" do
78+
result = described_class.call(article)
79+
expect(result).to be_a(Array)
80+
end
881

9-
it { is_expected.to be_a Array }
82+
it "handles article with nil cached_tag_list" do
83+
article.update_column(:cached_tag_list, nil)
84+
result = described_class.call(article.reload)
85+
expect(result).to be_a(Array)
86+
end
1087
end
1188
end

0 commit comments

Comments
 (0)