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:
parent
00ea4c4924
commit
4a4d83a6cf
8 changed files with 315 additions and 2 deletions
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
app/services/search/query_builders/feed_content.rb
Normal file
78
app/services/search/query_builders/feed_content.rb
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
spec/services/search/query_builders/feed_content_spec.rb
Normal file
86
spec/services/search/query_builders/feed_content_spec.rb
Normal 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
|
||||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue