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
|
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 :format_integer_params
|
||||||
before_action :sanitize_params, only: %i[classified_listings]
|
before_action :sanitize_params, only: %i[classified_listings reactions]
|
||||||
|
|
||||||
CLASSIFIED_LISTINGS_PARAMS = [
|
CLASSIFIED_LISTINGS_PARAMS = [
|
||||||
:category,
|
:category,
|
||||||
|
|
@ -13,6 +13,17 @@ class SearchController < ApplicationController
|
||||||
},
|
},
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
|
REACTION_PARAMS = [
|
||||||
|
:page,
|
||||||
|
:per_page,
|
||||||
|
:category,
|
||||||
|
:search_fields,
|
||||||
|
{
|
||||||
|
tag_names: [],
|
||||||
|
status: []
|
||||||
|
},
|
||||||
|
].freeze
|
||||||
|
|
||||||
USER_PARAMS = %i[
|
USER_PARAMS = %i[
|
||||||
search_fields
|
search_fields
|
||||||
page
|
page
|
||||||
|
|
@ -76,6 +87,14 @@ class SearchController < ApplicationController
|
||||||
render json: { result: feed_docs }
|
render json: { result: feed_docs }
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def feed_content_search
|
def feed_content_search
|
||||||
|
|
@ -111,6 +130,10 @@ class SearchController < ApplicationController
|
||||||
params.permit(FEED_PARAMS)
|
params.permit(FEED_PARAMS)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reaction_params
|
||||||
|
params.permit(REACTION_PARAMS)
|
||||||
|
end
|
||||||
|
|
||||||
def format_integer_params
|
def format_integer_params
|
||||||
params[:page] = params[:page].to_i if params[:page].present?
|
params[:page] = params[:page].to_i if params[:page].present?
|
||||||
params[:per_page] = params[:per_page].to_i if params[:per_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
|
DEFAULT_PER_PAGE = 80
|
||||||
|
|
||||||
class << self
|
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
|
private
|
||||||
|
|
||||||
def index_settings
|
def index_settings
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,7 @@ Rails.application.routes.draw do
|
||||||
get "/search/classified_listings" => "search#classified_listings"
|
get "/search/classified_listings" => "search#classified_listings"
|
||||||
get "/search/users" => "search#users"
|
get "/search/users" => "search#users"
|
||||||
get "/search/feed_content" => "search#feed_content"
|
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 "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
|
||||||
get "/listings/dashboard" => "classified_listings#dashboard"
|
get "/listings/dashboard" => "classified_listings#dashboard"
|
||||||
get "/listings/:category" => "classified_listings#index"
|
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)
|
expect(Search::FeedContent).to have_received(:search_documents)
|
||||||
end
|
end
|
||||||
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
|
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::INDEX_ALIAS).not_to be_nil
|
||||||
expect(described_class::MAPPINGS).not_to be_nil
|
expect(described_class::MAPPINGS).not_to be_nil
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue