implement backend search functionality for readinglist Reactions (#7308)
This commit is contained in:
parent
ccea13bc21
commit
2d40174a92
7 changed files with 337 additions and 2 deletions
|
|
@ -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?
|
||||
|
|
|
|||
84
app/services/search/query_builders/reaction.rb
Normal file
84
app/services/search/query_builders/reaction.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
90
spec/services/search/query_builders/reaction_spec.rb
Normal file
90
spec/services/search/query_builders/reaction_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue