Add Feed Content Backend Search Code (#6699) [deploy]

* Add search code for feed content to the backend

* add search for class_name
This commit is contained in:
Molly Struve 2020-03-18 14:51:26 -05:00 committed by GitHub
parent 00ea4c4924
commit 4a4d83a6cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 315 additions and 2 deletions

View file

@ -17,6 +17,16 @@ class SearchController < ApplicationController
per_page
].freeze
FEED_PARAMS = %i[
page
per_page
published_at
search_fields
sort_by
tag_names
user_id
].freeze
def tags
tag_docs = Search::Tag.search_documents("name:#{params[:name]}* AND supported:true")
@ -47,6 +57,12 @@ class SearchController < ApplicationController
render json: { result: user_docs }
end
def feed_content
feed_docs = Search::FeedContent.search_documents(params: feed_params.to_h)
render json: { result: feed_docs }
end
private
def chat_channel_params
@ -70,6 +86,10 @@ class SearchController < ApplicationController
params.permit(USER_PARAMS)
end
def feed_params
params.permit(FEED_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

@ -3,8 +3,21 @@ module Search
INDEX_NAME = "feed_content_#{Rails.env}".freeze
INDEX_ALIAS = "feed_content_#{Rails.env}_alias".freeze
MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/feed_content.json"), symbolize_names: true).freeze
DEFAULT_PAGE = 0
DEFAULT_PER_PAGE = 60
class << self
def search_documents(params:)
set_query_size(params)
query_hash = Search::QueryBuilders::FeedContent.new(params).as_hash
results = search(body: query_hash)
hits = results.dig("hits", "hits").map do |feed_doc|
feed_doc.dig("_source")
end
paginate_hits(hits, params)
end
private
def index_settings

View file

@ -0,0 +1,78 @@
module Search
module QueryBuilders
class FeedContent < QueryBase
QUERY_KEYS = %i[
search_fields
].freeze
TERM_KEYS = {
tag_names: "tags.name",
approved: "approved",
user_id: "user.id",
class_name: "class_name"
}.freeze
RANGE_KEYS = %i[
published_at
].freeze
DEFAULT_PARAMS = {
sort_by: "hotness_score",
sort_direction: "desc",
size: 0
}.freeze
attr_accessor :params, :body
def initialize(params)
@params = params.deep_symbolize_keys
build_body
end
private
def build_queries
@body[:query] = { bool: {} }
@body[:query][:bool][:filter] = filter_conditions if filter_keys_present?
@body[:query][:bool][:must] = query_conditions if query_keys_present?
end
def filter_conditions
filter_conditions = []
filter_conditions.concat(term_keys) if terms_keys_present?
filter_conditions.concat(range_keys) if range_keys_present?
filter_conditions
end
def filter_keys_present?
range_keys_present? || terms_keys_present?
end
def terms_keys_present?
TERM_KEYS.detect { |key, _| @params[key].present? }
end
def range_keys_present?
RANGE_KEYS.detect { |key| @params[key].present? }
end
def term_keys
TERM_KEYS.map do |term_key, search_key|
next unless @params.key? term_key
{ term: { search_key => @params[term_key] } }
end.compact
end
def range_keys
RANGE_KEYS.map do |range_key|
next unless @params.key? range_key
{ range: { range_key => @params[range_key] } }
end.compact
end
end
end
end

View file

@ -225,6 +225,7 @@ Rails.application.routes.draw do
get "/search/chat_channels" => "search#chat_channels"
get "/search/classified_listings" => "search#classified_listings"
get "/search/users" => "search#users"
get "/search/feed_content" => "search#feed_content"
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

@ -55,4 +55,32 @@ RSpec.describe "Search", type: :request, proper_status: true do
expect(response.parsed_body).to eq("result" => mock_documents)
end
end
describe "GET /search/users" do
let(:authorized_user) { create(:user) }
let(:mock_documents) { [{ "username" => "firstlast" }] }
it "returns json" do
sign_in authorized_user
allow(Search::User).to receive(:search_documents).and_return(
mock_documents,
)
get "/search/users"
expect(response.parsed_body).to eq("result" => mock_documents)
end
end
describe "GET /search/feed_content" do
let(:authorized_user) { create(:user) }
let(:mock_documents) { [{ "title" => "article1" }] }
it "returns json" do
sign_in authorized_user
allow(Search::FeedContent).to receive(:search_documents).and_return(
mock_documents,
)
get "/search/feed_content"
expect(response.parsed_body).to eq("result" => mock_documents)
end
end
end

View file

@ -6,4 +6,91 @@ RSpec.describe Search::FeedContent, 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) }
it "parses feed content 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
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([article1, article2])
query_params = { size: 5, search_fields: "ruby" }
feed_docs = described_class.search_documents(params: query_params)
expect(feed_docs.count).to eq(2)
doc_ids = feed_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(article1.id, article2.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([article1, article2])
query_params = { size: 5, tag_names: "ruby" }
feed_docs = described_class.search_documents(params: query_params)
expect(feed_docs.count).to eq(1)
doc_ids = feed_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(article1.id)
end
it "filters by user_id" do
index_documents([article1, article2])
query_params = { size: 5, user_id: article1.user_id }
feed_docs = described_class.search_documents(params: query_params)
expect(feed_docs.count).to eq(1)
doc_ids = feed_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(article1.id)
end
it "filters by approved" do
article1.update(approved: false)
article2.update(approved: true)
index_documents([article1, article2])
query_params = { size: 5, approved: true }
feed_docs = described_class.search_documents(params: query_params)
expect(feed_docs.count).to eq(1)
doc_ids = feed_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(article2.id)
end
it "filters by class_name" do
pde = create(:podcast_episode)
index_documents([pde, article1, article2])
query_params = { size: 5, class_name: "PodcastEpisode" }
feed_docs = described_class.search_documents(params: query_params)
expect(feed_docs.count).to eq(1)
doc_ids = feed_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(pde.id)
end
end
context "with range keys" do
it "searches by published_at" do
article1.update(published_at: 1.year.ago)
article2.update(published_at: 1.month.ago)
index_documents([article1, article2])
query_params = { size: 5, published_at: { gte: 2.months.ago.iso8601 } }
feed_docs = described_class.search_documents(params: query_params)
expect(feed_docs.count).to eq(1)
doc_ids = feed_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(article2.id)
end
end
end
end

View file

@ -0,0 +1,86 @@
require "rails_helper"
RSpec.describe Search::QueryBuilders::FeedContent, type: :service do
describe "::intialize" do
it "sets params" do
filter_params = { foo: "bar" }
filter = described_class.new(filter_params)
expect(filter.params).to include(filter_params)
end
it "builds query body" do
filter = described_class.new({})
expect(filter.body).not_to be_nil
end
end
describe "#as_hash" do
it "applies QUERY_KEYS from params" do
params = { search_fields: "test" }
filter = described_class.new(params)
exepcted_query = [{
"simple_query_string" => {
"query" => "test*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true
}
}]
expect(filter.as_hash.dig("query", "bool", "must")).to match_array(exepcted_query)
end
it "applies TERM_KEYS from params" do
params = { approved: true, tag_names: "beginner", user_id: 777, class_name: "Article" }
filter = described_class.new(params)
exepcted_filters = [
{ "term" => { "approved" => true } },
{ "term" => { "tags.name" => "beginner" } },
{ "term" => { "user.id" => 777 } },
{ "term" => { "class_name" => "Article" } },
]
expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters)
end
it "applies RANGE_KEYS from params" do
Timecop.freeze(Time.current) do
params = { published_at: { lte: Time.current } }
filter = described_class.new(params)
exepcted_filters = [
{ "range" => { "published_at" => { lte: Time.current } } },
]
expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters)
end
end
it "applies QUERY_KEYS, TERM_KEYS, and RANGE_KEYS from params" do
Timecop.freeze(Time.current) do
params = { search_fields: "ruby", published_at: { lte: Time.current }, tag_names: "cfp" }
filter = described_class.new(params)
exepcted_query = [{
"simple_query_string" => { "query" => "ruby*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true }
}]
exepcted_filters = [
{ "range" => { "published_at" => { lte: Time.current } } },
{ "term" => { "tags.name" => "cfp" } },
]
expect(filter.as_hash.dig("query", "bool", "must")).to match_array(exepcted_query)
expect(filter.as_hash.dig("query", "bool", "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)
exepcted_query = [{
"simple_query_string" => {
"query" => "cfp*", "fields" => [:search_fields], "lenient" => true, "analyze_wildcard" => true
}
}]
expect(filter.as_hash.dig("query", "bool", "must")).to match_array(exepcted_query)
end
it "allows default params to be overriden" do
params = { sort_by: "published_at", sort_direction: "asc", size: 20 }
filter = described_class.new(params).as_hash
expect(filter.dig("sort")).to eq("published_at" => "asc")
expect(filter.dig("size")).to eq(20)
end
end
end

View file

@ -1,13 +1,13 @@
require "rails_helper"
RSpec.describe Search::User, type: :service, elasticsearch: true do
RSpec.describe Search::User, type: :service do
it "defines INDEX_NAME, INDEX_ALIAS, and MAPPINGS", :aggregate_failures do
expect(described_class::INDEX_NAME).not_to be_nil
expect(described_class::INDEX_ALIAS).not_to be_nil
expect(described_class::MAPPINGS).not_to be_nil
end
describe "::search_documents" do
describe "::search_documents", elasticsearch: true do
let(:user1) { create(:user) }
let(:user2) { create(:user) }