[Search 2.0] Add query filters for profile, organization and tag index pages (#13510)

* Re-order test cases to increase tests readability

* Add user_id filter for articles query

* Add organization_id and tags filters

* Add new params to SearchController#feed_content

* Add current_user param so that it can be tested for a small pool of users
This commit is contained in:
rhymes 2021-04-27 08:49:47 +02:00 committed by GitHub
parent 333c41a962
commit 8a55c3e888
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 246 additions and 95 deletions

View file

@ -126,31 +126,40 @@ class SearchController < ApplicationController
def feed_content
class_name = feed_params[:class_name].to_s.inquiry
enable_search_2_homepage = (
class_name.Article? &&
feed_params[:search_fields].blank? &&
FeatureFlag.enabled?(:search_2_homepage, current_user)
)
result =
if class_name.blank?
# If we are in the main feed and not filtering by type return
# all articles, podcast episodes, and users
feed_content_search.concat(user_search)
elsif class_name.Article? && feed_params[:search_fields].blank?
# homepage
if FeatureFlag.enabled?(:search_2_homepage)
# NOTE: published_at is sent from the frontend in the following ES-friendly format:
# => {"published_at"=>{"gte"=>"2021-04-06T14:53:23Z"}}
published_at_gte = params.dig(:published_at, :gte)
published_at_gte = Time.zone.parse(published_at_gte) if published_at_gte
published_at = published_at_gte ? published_at_gte.. : nil
elsif enable_search_2_homepage
# NOTE: published_at is sent from the frontend in the following ES-friendly format:
# => {"published_at"=>{"gte"=>"2021-04-06T14:53:23Z"}}
published_at_gte = feed_params.dig(:published_at, :gte)
published_at_gte = Time.zone.parse(published_at_gte) if published_at_gte
published_at = published_at_gte ? published_at_gte.. : nil
Homepage::FetchArticles.call(
approved: params[:approved],
published_at: published_at,
sort_by: params[:sort_by],
sort_direction: params[:sort_direction],
page: params[:page],
per_page: params[:per_page],
)
else
feed_content_search
end
# Despite the name "Homepage", this is used by the following index pages:
# => homepage (default, top week/month/year/infinity, latest)
# => profile page
# => organization page
# => tag index page
Homepage::FetchArticles.call(
approved: feed_params[:approved],
published_at: published_at,
user_id: feed_params[:user_id],
organization_id: feed_params[:organization_id],
tags: feed_params[:tag_names],
sort_by: params[:sort_by],
sort_direction: params[:sort_direction],
page: params[:page],
per_page: params[:per_page],
)
elsif class_name.Comment? && FeatureFlag.enabled?(:search_2_comments)
Search::Postgres::Comment.search_documents(
page: feed_params[:page],

View file

@ -25,12 +25,24 @@ module Homepage
# TODO: [@rhymes] change frontend to start from page 1
def initialize(
approved: nil, published_at: nil, sort_by: nil, sort_direction: nil, page: 0, per_page: DEFAULT_PER_PAGE
approved: nil,
published_at: nil,
user_id: nil,
organization_id: nil,
tags: [],
sort_by: nil,
sort_direction: nil,
page: 0,
per_page: DEFAULT_PER_PAGE
)
@relation = Article.published.select(*ATTRIBUTES)
@approved = approved
@published_at = published_at
@user_id = user_id
@organization_id = organization_id
@tags = tags.presence || []
@sort_by = sort_by
@sort_direction = sort_direction
@ -44,13 +56,22 @@ module Homepage
private
attr_reader :relation, :approved, :published_at, :sort_by, :sort_direction, :page, :per_page
attr_reader :relation, :approved, :published_at, :user_id, :organization_id, :tags, :sort_by, :sort_direction,
:page, :per_page
def filter
return relation if approved.nil? && published_at.blank?
@relation = @relation.where(approved: approved) unless approved.nil?
@relation = @relation.where(published_at: published_at) if published_at
@relation = @relation.where(published_at: published_at) if published_at.present?
@relation = @relation.where(user_id: user_id) if user_id.present?
@relation = @relation.where(organization_id: organization_id) if organization_id.present?
# as tags are in `OR` mode we can't use ActiveRecord's `.or()` because it
# would put all the previous filters in `OR` mode with tags, but what we need
# is to only consider tags as a `OR` sub-condition
if tags.present?
conditions = tags.map { |tag| relation.sanitize_sql_array(["cached_tag_list LIKE ?", "%#{tag}%"]) }
@relation = @relation.where(conditions.join(" OR "))
end
relation
end

View file

@ -1,13 +1,30 @@
# This is used to populate the following pages:
# => homepage
# => profile page
# => organization page
# => tag index page
# TODO: rename `Homepage::FetchArticles` to something more generic
module Homepage
class FetchArticles
DEFAULT_PER_PAGE = 60
def self.call(
approved: nil, published_at: nil, sort_by: nil, sort_direction: nil, page: 0, per_page: DEFAULT_PER_PAGE
approved: nil,
published_at: nil,
user_id: nil,
organization_id: nil,
tags: [],
sort_by: nil,
sort_direction: nil,
page: 0,
per_page: DEFAULT_PER_PAGE
)
articles = Homepage::ArticlesQuery.call(
approved: approved,
published_at: published_at,
user_id: user_id,
organization_id: organization_id,
tags: tags,
sort_by: sort_by,
sort_direction: sort_direction,
page: page,

View file

@ -6,100 +6,180 @@ RSpec.describe Homepage::ArticlesQuery, type: :query do
expect(described_class.call).to be_a(ActiveRecord::Relation)
end
it "does not return draft articles" do
article = create(:article, published: false, published_at: nil)
expect(described_class.call.ids).not_to include(article.id)
end
it "returns both approved and unapproved articles by default" do
approved_article = create(:article, approved: true)
unapproved_article = create(:article, approved: false)
expected_result = [approved_article.id, unapproved_article.id]
expect(described_class.call.ids).to match_array(expected_result)
end
it "returns approved articles", :aggregate_failures do
approved_article = create(:article, approved: true)
unapproved_article = create(:article, approved: false)
result = described_class.call(approved: true).ids
expect(result).to include(approved_article.id)
expect(result).not_to include(unapproved_article.id)
end
it "returns unapproved articles" do
approved_article = create(:article, approved: true)
unapproved_article = create(:article, approved: false)
result = described_class.call(approved: false).ids
expect(result).not_to include(approved_article.id)
expect(result).to include(unapproved_article.id)
end
it "returns only published articles" do
article = create(:article)
expect(described_class.call.ids).to eq([article.id])
end
it "paginates by default" do
stub_const("Homepage::ArticlesQuery::DEFAULT_PER_PAGE", 1)
it "does not return draft articles" do
article = create(:article, published: false, published_at: nil)
create_list(:article, 2)
expect(described_class.call.size).to eq(1)
expect(described_class.call.ids).not_to include(article.id)
end
it "supports pagination params" do
create_list(:article, 2)
describe "approved" do
it "returns both approved and unapproved articles by default" do
approved_article = create(:article, approved: true)
unapproved_article = create(:article, approved: false)
expect(described_class.call(page: 1, per_page: 1).size).to eq(1)
expected_result = [approved_article.id, unapproved_article.id]
expect(described_class.call.ids).to match_array(expected_result)
end
it "returns approved articles", :aggregate_failures do
approved_article = create(:article, approved: true)
unapproved_article = create(:article, approved: false)
result = described_class.call(approved: true).ids
expect(result).to include(approved_article.id)
expect(result).not_to include(unapproved_article.id)
end
it "returns unapproved articles" do
approved_article = create(:article, approved: true)
unapproved_article = create(:article, approved: false)
result = described_class.call(approved: false).ids
expect(result).not_to include(approved_article.id)
expect(result).to include(unapproved_article.id)
end
end
it "filters by publication date", :aggregate_failures do
article = create(:article)
describe "published_at" do
it "filters by publication date", :aggregate_failures do
article = create(:article)
expect(described_class.call(published_at: article.published_at).size).to eq(1)
expect(described_class.call(published_at: 1.month.ago..).size).to eq(1)
expect(described_class.call(published_at: 1.month.from_now)).to be_empty
expect(described_class.call(published_at: nil).size).to eq(1)
expect(described_class.call(published_at: article.published_at).size).to eq(1)
expect(described_class.call(published_at: 1.month.ago..).size).to eq(1)
expect(described_class.call(published_at: 1.month.from_now)).to be_empty
end
end
it "sorts by the hotness_score", :aggregate_failures do
article1, article2 = create_list(:article, 2)
describe "user_id" do
it "returns no articles if the user id does not exist" do
expect(described_class.call(user_id: 9999)).to be_empty
end
article1.update_columns(hotness_score: 1)
article2.update_columns(hotness_score: 2)
it "filters articles belonging to the given user id", :aggregate_failures do
article_user1 = create(:article)
article_user2 = create(:article, user: create(:user))
result = described_class.call(sort_by: :hotness_score, sort_direction: :desc).ids
expect(result).to eq([article2.id, article1.id])
result = described_class.call(sort_by: :hotness_score, sort_direction: :asc).ids
expect(result).to eq([article1.id, article2.id])
expect(described_class.call(user_id: article_user1.user_id).ids).to include(article_user1.id)
expect(described_class.call(user_id: article_user1.user_id).ids).not_to include(article_user2.id)
end
end
it "sorts by the public_reactions_count" do
article1, article2 = create_list(:article, 2)
describe "organization_id" do
it "returns no articles if the organization id does not exist" do
expect(described_class.call(organization_id: 9999)).to be_empty
end
article1.update_columns(public_reactions_count: 1)
article2.update_columns(public_reactions_count: 2)
it "filters articles belonging to the given organization id", :aggregate_failures do
org1 = create(:organization)
org2 = create(:organization)
article_org1 = create(:article, organization: org1)
article_org2 = create(:article, organization: org2)
article_no_org = create(:article)
result = described_class.call(sort_by: :public_reactions_count, sort_direction: :desc).ids
expect(result).to eq([article2.id, article1.id])
result = described_class.call(sort_by: :public_reactions_count, sort_direction: :asc).ids
expect(result).to eq([article1.id, article2.id])
expect(described_class.call(organization_id: org1.id).ids).to include(article_org1.id)
expect(described_class.call(organization_id: org1.id).ids).not_to include(article_org2.id)
expect(described_class.call(organization_id: org1.id).ids).not_to include(article_no_org.id)
end
end
it "does not sort by unknown parameters" do
article1, article2 = create_list(:article, 2)
describe "tags" do
let(:article1) { create(:article, with_tags: false) }
let(:article2) { create(:article, with_tags: false) }
article1.update_columns(comments_count: 1)
article2.update_columns(comments_count: 2)
it "returns no articles if none of the tags match" do
article1.tag_list.add(:beginners)
article1.save
article2.tag_list.add(:beginners)
article2.save
result = described_class.call(sort_by: :comments_count, sort_direction: :desc).ids
expect(result).not_to eq([article2.id, article1.id])
expect(described_class.call(tags: [:ruby])).to be_empty
end
it "filters articles matching the tag" do
article1.tag_list.add(:beginners)
article1.save
expect(described_class.call(tags: [:beginners]).ids).to eq([article1.id])
end
it "filters any article matching any of the tags in the params", :aggregate_failures do
article1.tag_list.add(:beginners)
article1.save
article2.tag_list.add(:ruby)
article2.save
expect(described_class.call(tags: %i[beginners python]).ids).to eq([article1.id])
end
it "filters all articles match any of the tags in the params" do
article1.tag_list.add(:beginners)
article1.save
article2.tag_list.add(:ruby)
article2.save
expect(described_class.call(tags: %i[beginners ruby]).ids).to match_array([article1.id, article2.id])
end
end
describe "pagination" do
it "paginates by default" do
stub_const("Homepage::ArticlesQuery::DEFAULT_PER_PAGE", 1)
create_list(:article, 2)
expect(described_class.call.size).to eq(1)
end
it "supports pagination params" do
create_list(:article, 2)
expect(described_class.call(page: 1, per_page: 1).size).to eq(1)
end
end
describe "sorting" do
it "sorts by the hotness_score", :aggregate_failures do
article1, article2 = create_list(:article, 2)
article1.update_columns(hotness_score: 1)
article2.update_columns(hotness_score: 2)
result = described_class.call(sort_by: :hotness_score, sort_direction: :desc).ids
expect(result).to eq([article2.id, article1.id])
result = described_class.call(sort_by: :hotness_score, sort_direction: :asc).ids
expect(result).to eq([article1.id, article2.id])
end
it "sorts by the public_reactions_count" do
article1, article2 = create_list(:article, 2)
article1.update_columns(public_reactions_count: 1)
article2.update_columns(public_reactions_count: 2)
result = described_class.call(sort_by: :public_reactions_count, sort_direction: :desc).ids
expect(result).to eq([article2.id, article1.id])
result = described_class.call(sort_by: :public_reactions_count, sort_direction: :asc).ids
expect(result).to eq([article1.id, article2.id])
end
it "does not sort by unknown parameters" do
article1, article2 = create_list(:article, 2)
article1.update_columns(comments_count: 1)
article2.update_columns(comments_count: 2)
result = described_class.call(sort_by: :comments_count, sort_direction: :desc).ids
expect(result).not_to eq([article2.id, article1.id])
end
end
end
end

View file

@ -235,7 +235,7 @@ RSpec.describe "Search", type: :request, proper_status: true do
context "when using PostgreSQL for the homepage" do
before do
allow(FeatureFlag).to receive(:enabled?).with(:search_2_homepage).and_return(true)
allow(FeatureFlag).to receive(:enabled?).with(:search_2_homepage, anything).and_return(true)
end
it "does not call Homepage::FetchArticles when class_name is Article with a search term", :aggregate_failures do
@ -274,6 +274,30 @@ RSpec.describe "Search", type: :request, proper_status: true do
get search_feed_content_path(class_name: "Article", published_at: { gte: datetime.iso8601 })
expect(response.parsed_body["result"]).to be_empty
end
it "supports the user_id parameter" do
allow(Homepage::FetchArticles).to receive(:call)
get search_feed_content_path(class_name: "Article", user_id: 1)
expect(Homepage::FetchArticles).to have_received(:call).with(hash_including(user_id: "1"))
end
it "supports the organization_id parameter" do
allow(Homepage::FetchArticles).to receive(:call)
get search_feed_content_path(class_name: "Article", organization_id: 1)
expect(Homepage::FetchArticles).to have_received(:call).with(hash_including(organization_id: "1"))
end
it "supports the tag_names parameter" do
allow(Homepage::FetchArticles).to receive(:call)
get search_feed_content_path(class_name: "Article", tag_names: %i[ruby])
expect(Homepage::FetchArticles).to have_received(:call).with(hash_including(tags: %w[ruby]))
end
end
context "when using PostgreSQL for comments" do