implement backend search functionality for readinglist Reactions (#7308)

This commit is contained in:
Molly Struve 2020-04-17 08:37:22 -05:00 committed by GitHub
parent ccea13bc21
commit 2d40174a92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 337 additions and 2 deletions

View file

@ -1,7 +1,7 @@
class SearchController < ApplicationController
before_action :authenticate_user!, only: %i[tags chat_channels]
before_action :authenticate_user!, only: %i[tags chat_channels reactions]
before_action :format_integer_params
before_action :sanitize_params, only: %i[classified_listings]
before_action :sanitize_params, only: %i[classified_listings reactions]
CLASSIFIED_LISTINGS_PARAMS = [
:category,
@ -13,6 +13,17 @@ class SearchController < ApplicationController
},
].freeze
REACTION_PARAMS = [
:page,
:per_page,
:category,
:search_fields,
{
tag_names: [],
status: []
},
].freeze
USER_PARAMS = %i[
search_fields
page
@ -76,6 +87,14 @@ class SearchController < ApplicationController
render json: { result: feed_docs }
end
def reactions
result = Search::Reaction.search_documents(
params: reaction_params.merge(user_id: current_user.id).to_h,
)
render json: { result: result["reactions"], total: result["total"] }
end
private
def feed_content_search
@ -111,6 +130,10 @@ class SearchController < ApplicationController
params.permit(FEED_PARAMS)
end
def reaction_params
params.permit(REACTION_PARAMS)
end
def format_integer_params
params[:page] = params[:page].to_i if params[:page].present?
params[:per_page] = params[:per_page].to_i if params[:per_page].present?

View file

@ -0,0 +1,84 @@
module Search
module QueryBuilders
class Reaction < QueryBase
QUERY_KEYS = {
search_fields: [
"reactable.tags.keywords_for_search",
"reactable.tags.name^3",
"reactable.body_text^2",
"reactable.title^6",
"reactable.user.name",
"reactable.user.username",
]
}.freeze
# Search keys from our controllers may not match what we have stored in Elasticsearch so we map them here,
# this allows us to change our Elasticsearch docs without worrying about the frontend
TERM_KEYS = {
category: "category",
tag_names: "reactable.tags.name",
user_id: "user_id",
status: "status"
}.freeze
DEFAULT_PARAMS = {
sort_by: "id",
sort_direction: "desc",
size: 0
}.freeze
SOURCE = %i[
id
reactable.path
reactable.published_date_string
reactable.reading_time
reactable.tags
reactable.title
reactable.user
].freeze
attr_accessor :params, :body
def initialize(params:)
@params = params.deep_symbolize_keys
# Default to only readinglist reactions
@params[:category] = "readinglist"
build_body
end
private
def build_queries
@body[:query] = { bool: {} }
@body[:query][:bool][:filter] = terms_keys if terms_keys_present?
@body[:query][:bool][:must] = query_conditions if query_keys_present?
end
def terms_keys_present?
TERM_KEYS.detect { |key, _| @params[key].present? }
end
def terms_keys
TERM_KEYS.map do |term_key, search_key|
next unless @params.key? term_key
{ terms: { search_key => Array.wrap(@params[term_key]) } }
end.compact
end
def query_hash(key, fields)
{
simple_query_string: {
query: key,
fields: fields,
lenient: true,
analyze_wildcard: true,
minimum_should_match: 2
}
}
end
end
end
end

View file

@ -7,6 +7,18 @@ module Search
DEFAULT_PER_PAGE = 80
class << self
def search_documents(params:)
set_query_size(params)
query_hash = "Search::QueryBuilders::#{name.demodulize}".safe_constantize.new(params: params).as_hash
results = search(body: query_hash)
hits = results.dig("hits", "hits").map { |hit| prepare_doc(hit) }
{
"reactions" => paginate_hits(hits, params),
"total" => results.dig("hits", "total", "value")
}
end
private
def index_settings

View file

@ -234,6 +234,7 @@ Rails.application.routes.draw do
get "/search/classified_listings" => "search#classified_listings"
get "/search/users" => "search#users"
get "/search/feed_content" => "search#feed_content"
get "/search/reactions" => "search#reactions"
get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
get "/listings/dashboard" => "classified_listings#dashboard"
get "/listings/:category" => "classified_listings#index"

View file

@ -113,4 +113,31 @@ RSpec.describe "Search", type: :request, proper_status: true do
expect(Search::FeedContent).to have_received(:search_documents)
end
end
describe "GET /search/reactions" do
let(:authorized_user) { create(:user) }
let(:mock_response) do
{ "reactions" => [{ id: 123 }], "total" => 100 }
end
it "returns json with reactions and total" do
sign_in authorized_user
allow(Search::Reaction).to receive(:search_documents).and_return(
mock_response,
)
get "/search/reactions"
expect(response.parsed_body).to eq("result" => [{ "id" => 123 }], "total" => 100)
end
it "accepts array of tag names" do
sign_in authorized_user
allow(Search::Reaction).to receive(:search_documents).and_return(
mock_response,
)
get "/search/reactions?tag_names[]=1&tag_names[]=2"
expect(Search::Reaction).to have_received(
:search_documents,
).with(params: { "tag_names" => %w[1 2], "user_id" => authorized_user.id })
end
end
end

View file

@ -0,0 +1,90 @@
require "rails_helper"
RSpec.describe Search::QueryBuilders::Reaction, type: :service do
describe "::initialize" do
it "sets params" do
filter_params = { foo: "bar" }
filter = described_class.new(params: filter_params)
expect(filter.params).to include(filter_params)
end
it "builds query body" do
filter = described_class.new(params: {})
expect(filter.body).not_to be_nil
end
it "sets category to readinglist" do
filter = described_class.new(params: {})
expect(filter.params).to include(category: "readinglist")
end
end
describe "#as_hash" do
let(:query_fields) { described_class::QUERY_KEYS[:search_fields] }
it "applies QUERY_KEYS from params" do
params = { search_fields: "test" }
filter = described_class.new(params: params)
exepcted_query = [{
"simple_query_string" => {
"query" => "test",
"fields" => query_fields,
"lenient" => true,
"analyze_wildcard" => true,
"minimum_should_match" => 2
}
}]
expect(search_bool_clause(filter)["must"]).to match_array(exepcted_query)
end
it "applies TERM_KEYS from params" do
params = { tag_names: "beginner", user_id: 777, status: "valid" }
filter = described_class.new(params: params)
exepcted_filters = [
{ "terms" => { "status" => ["valid"] } },
{ "terms" => { "reactable.tags.name" => ["beginner"] } },
{ "terms" => { "user_id" => [777] } },
{ "terms" => { "category" => ["readinglist"] } },
]
expect(search_bool_clause(filter)["filter"]).to match_array(exepcted_filters)
end
it "applies QUERY_KEYS and TERM_KEYS from params" do
Timecop.freeze(Time.current) do
params = { search_fields: "ruby", tag_names: "cfp" }
filter = described_class.new(params: params)
exepcted_query = [{
"simple_query_string" => { "query" => "ruby", "fields" => query_fields, "lenient" => true, "analyze_wildcard" => true, "minimum_should_match" => 2 }
}]
exepcted_filters = [
{ "terms" => { "reactable.tags.name" => ["cfp"] } },
{ "terms" => { "category" => ["readinglist"] } },
]
expect(search_bool_clause(filter)["must"]).to match_array(exepcted_query)
expect(search_bool_clause(filter)["filter"]).to match_array(exepcted_filters)
end
end
it "ignores params we don't support" do
params = { not_supported: "trash", search_fields: "cfp" }
filter = described_class.new(params: params)
exepcted_query = [{
"simple_query_string" => {
"query" => "cfp", "fields" => query_fields, "lenient" => true, "analyze_wildcard" => true, "minimum_should_match" => 2
}
}]
expect(search_bool_clause(filter)["must"]).to match_array(exepcted_query)
end
it "allows default params to be overriden" do
params = { sort_by: "status", sort_direction: "asc", size: 20 }
filter = described_class.new(params: params).as_hash
expect(filter.dig("sort")).to eq("status" => "asc")
expect(filter.dig("size")).to eq(20)
end
end
def search_bool_clause(query_builder)
query_builder.as_hash.dig("query", "bool")
end
end

View file

@ -6,4 +6,102 @@ RSpec.describe Search::Reaction, type: :service do
expect(described_class::INDEX_ALIAS).not_to be_nil
expect(described_class::MAPPINGS).not_to be_nil
end
describe "::search_documents", elasticsearch: true do
let(:article1) { create(:article) }
let(:article2) { create(:article) }
let(:reaction1) { create(:reaction, category: "readinglist", reactable: article1) }
let(:reaction2) { create(:reaction, category: "readinglist", reactable: article2) }
let(:query_params) { { size: 5 } }
it "parses reaction document hits from search response" do
mock_search_response = { "hits" => { "hits" => {} } }
allow(described_class).to receive(:search) { mock_search_response }
described_class.search_documents(params: {})
expect(described_class).to have_received(:search).with(body: a_kind_of(Hash))
end
it "returns fields necessary for the view" do
view_keys = %w[id reactable]
reactable_keys = %w[title path published_date_string tags user]
user_keys = %w[username name profile_image_90]
index_documents([reaction1])
result = described_class.search_documents(params: { size: 1 })
reaction_doc = result["reactions"].first
expect(result["total"]).to be_present
expect(reaction_doc.keys).to include(*view_keys)
expect(reaction_doc["reactable"].keys).to include(*reactable_keys)
expect(reaction_doc.dig("reactable", "user").keys).to include(*user_keys)
end
context "with a query" do
it "searches by search_fields" do
allow(article1).to receive(:title).and_return("ruby")
allow(article2).to receive(:body_text).and_return("Ruby Tuesday")
index_documents([reaction1, reaction2])
query_params[:search_fields] = "ruby"
reaction_docs = described_class.search_documents(params: query_params)["reactions"]
expect(reaction_docs.count).to eq(2)
doc_ids = reaction_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(reaction1.id, reaction2.id)
end
end
context "with a filter term" do
it "filters by tag names" do
article1.tags << create(:tag, name: "ruby")
article2.tags << create(:tag, name: "python")
index_documents([reaction1, reaction2])
query_params[:tag_names] = "ruby"
reaction_docs = described_class.search_documents(params: query_params)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(reaction1.id)
end
it "filters by user_id" do
index_documents([reaction1, reaction2])
query_params[:user_id] = reaction1.user_id
reaction_docs = described_class.search_documents(params: query_params)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(reaction1.id)
end
it "filters by status" do
reaction1.update(status: "invalid")
index_documents([reaction1, reaction2])
query_params[:status] = ["valid"]
reaction_docs = described_class.search_documents(params: query_params)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(reaction2.id)
end
it "filters by category by default" do
reaction1.update(category: "like")
index_documents([reaction1, reaction2])
reaction_docs = described_class.search_documents(params: query_params)["reactions"]
expect(reaction_docs.count).to eq(1)
doc_ids = reaction_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(reaction2.id)
end
end
context "with default sorting" do
it "sorts by id" do
index_documents([reaction1, reaction2])
reaction_docs = described_class.search_documents(params: query_params)["reactions"]
doc_ids = reaction_docs.map { |t| t.dig("id") }
expect(doc_ids).to eq([reaction2.id, reaction1.id])
end
end
end
end