Search ClassifiedListings backend code (#6348)

* Add search methods

* Add spec for new search methods

* Add QueryBuilder for ClassifiedListing

* Add QueryBuilder spec for ClassifiedListing

* Refact build_query in ClassifiedListings

* Refactor logic for filter and query conditions

* Refactor index_documents to helper_method

* Update elasticsearch helper for single objects

* Refactor specs from stubbing to record update

* Update search and specs for query_builder

* Refactor set_size

* Update Search::ClassifiedListing

- Add DEFAULT_PAGE constant
- ADD DEFAULT_PER_PAGE constant
- Add pagination

* Fix paging

* Add pagination specs

* Remove manual Array conversion for Array.wrap()

* Change guard clause to .key? for readability
This commit is contained in:
Alex 2020-02-28 07:51:33 -08:00 committed by GitHub
parent 66e0637e4c
commit e40c28117b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 416 additions and 0 deletions

View file

@ -3,10 +3,38 @@ module Search
INDEX_NAME = "classified_listings_#{Rails.env}".freeze
INDEX_ALIAS = "classified_listings_#{Rails.env}_alias".freeze
MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/classified_listings.json"), symbolize_names: true).freeze
DEFAULT_PAGE = 0
DEFAULT_PER_PAGE = 75
class << self
def search_documents(params:)
set_query_size(params)
query_hash = Search::QueryBuilders::ClassifiedListing.new(params).as_hash
results = search(body: query_hash)
hits = results.dig("hits", "hits").map { |cl_doc| cl_doc.dig("_source") }
paginate_hits(hits, params)
end
private
def search(body:)
SearchClient.search(index: INDEX_ALIAS, body: body)
end
def set_query_size(params)
params[:page] ||= DEFAULT_PAGE
params[:per_page] ||= DEFAULT_PER_PAGE
# pages start at 0
params[:size] = params[:per_page].to_i * (params[:page].to_i + 1)
end
def paginate_hits(hits, params)
start = params[:per_page] * params[:page]
hits[start, params[:per_page]] || []
end
def index_settings
if Rails.env.production?
{

View file

@ -0,0 +1,124 @@
module Search
module QueryBuilders
class ClassifiedListing
TERM_KEYS = %i[
category
contact_via_connect
location
slug
tags
title
user_id
].freeze
RANGE_KEYS = %i[
bumped_at
expires_at
].freeze
QUERY_KEYS = %i[
classified_listing_search
].freeze
DEFAULT_PARAMS = {
sort_by: "bumped_at",
sort_direction: "desc",
size: 0
}.freeze
attr_accessor :params, :body
def initialize(params)
@params = params.deep_symbolize_keys
build_body
end
def as_hash
@body
end
private
def build_body
@body = ActiveSupport::HashWithIndifferentAccess.new
build_queries
add_sort
set_size
end
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 add_sort
sort_key = @params[:sort_by] || DEFAULT_PARAMS[:sort_by]
sort_direction = @params[:sort_direction] || DEFAULT_PARAMS[:sort_direction]
@body[:sort] = {
sort_key => sort_direction
}
end
def set_size
# By default we will return 0 documents if size is not specified
@body[:size] = @params[:size] || DEFAULT_PARAMS[:size]
end
def filter_conditions
filter_conditions = []
filter_conditions.concat term_keys if term_keys_present?
filter_conditions.concat range_keys if range_keys_present?
filter_conditions
end
def term_keys
TERM_KEYS.map do |term_key|
next unless @params.key? term_key
{ term: { term_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
def filter_keys_present?
term_keys_present? || range_keys_present?
end
def term_keys_present?
TERM_KEYS.detect { |key| @params[key].present? }
end
def range_keys_present?
RANGE_KEYS.detect { |key| @params[key].present? }
end
def query_keys_present?
QUERY_KEYS.detect { |key| @params[key].present? }
end
def query_conditions
QUERY_KEYS.map do |query_key|
next if @params[query_key].blank?
{
simple_query_string: {
query: "#{@params[query_key]}*",
fields: [query_key],
lenient: true,
analyze_wildcard: true
}
}
end.compact
end
end
end
end

View file

@ -77,6 +77,7 @@ RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
config.include OmniauthMacros
config.include SidekiqTestHelpers
config.include ElasticsearchHelpers, elasticsearch: true
config.before do
ActiveRecord::Base.observers.disable :all # <-- Turn 'em all off!

View file

@ -100,4 +100,173 @@ RSpec.describe Search::ClassifiedListing, type: :service, elasticsearch: true do
described_class.delete_index(index_name: other_name)
end
end
describe "::search_documents" do
let(:classified_listing) { create(:classified_listing) }
it "parses classified_listing 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
# classified_listing_search is a copy_to field including:
# body_markdown, location, slug, tags, and title
it "searches by classified_listing_search" do
classified_listing1 = create(:classified_listing, body_markdown: "# body_markdown with test")
classified_listing2 = create(:classified_listing, location: "a test location")
classified_listing3 = create(:classified_listing, title: "this test title is testing slug")
classified_listing4 = create(:classified_listing, tag_list: ["test"])
classified_listing5 = create(:classified_listing, title: "a test title")
classified_listings = [classified_listing1, classified_listing2, classified_listing3, classified_listing4, classified_listing5]
index_documents(classified_listings)
classified_listing_docs = described_class.search_documents(params: { size: 5, classified_listing_search: "test" })
expect(classified_listing_docs.count).to eq(5)
expect(classified_listing_docs.map { |t| t.dig("id") }).to match_array(classified_listings.map(&:id))
end
end
context "with a term filter" do
it "searches by category" do
classified_listing.update(category: "forhire")
index_documents(classified_listing)
params = { size: 5, category: "forhire" }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "searches by contact_via_connect" do
classified_listing.update(contact_via_connect: true)
index_documents(classified_listing)
params = { size: 5, contact_via_connect: true }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "searches by location" do
classified_listing.update(location: "a location")
index_documents(classified_listing)
params = { size: 5, location: "location" }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "searches by slug" do
slug_classified_listing = FactoryBot.create(:classified_listing, title: "A slug is created from this title in a callback")
index_documents(slug_classified_listing)
params = { size: 5, slug: "slug" }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(slug_classified_listing.id)
end
it "searches by tags" do
classified_listing.update(tag_list: %w[beginners career])
index_documents(classified_listing)
params = { size: 5, tags: "career" }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "searches by title" do
classified_listing.update(title: "An Amazing Title")
index_documents(classified_listing)
params = { size: 5, title: "amazing" }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "searches by user_id" do
index_documents(classified_listing)
params = { size: 5, user_id: classified_listing.user_id }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "searches by bumped_at" do
classified_listing.update(bumped_at: 1.day.from_now)
index_documents(classified_listing)
params = { size: 5, bumped_at: { gt: Time.current } }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "searches by expires_at" do
classified_listing.update(expires_at: 1.day.ago)
index_documents(classified_listing)
params = { size: 5, expires_at: { lt: Time.current } }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(1)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
end
it "sorts documents for a given field" do
classified_listing.update(category: "forhire")
classified_listing2 = FactoryBot.create(:classified_listing, category: "cfp")
index_documents([classified_listing, classified_listing2])
params = { size: 5, sort_by: "category", sort_direction: "asc" }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(2)
expect(classified_listing_docs.first["id"]).to eq(classified_listing2.id)
expect(classified_listing_docs.last["id"]).to eq(classified_listing.id)
end
it "sorts documents by bumped_at by default" do
classified_listing.update(bumped_at: 1.year.ago)
classified_listing2 = FactoryBot.create(:classified_listing, bumped_at: Time.current)
index_documents([classified_listing, classified_listing2])
params = { size: 5 }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs.count).to eq(2)
expect(classified_listing_docs.first["id"]).to eq(classified_listing2.id)
expect(classified_listing_docs.last["id"]).to eq(classified_listing.id)
end
it "paginates the results" do
classified_listing.update(bumped_at: 1.year.ago)
classified_listing2 = FactoryBot.create(:classified_listing, bumped_at: Time.current)
index_documents([classified_listing, classified_listing2])
first_page_params = { page: 0, per_page: 1, sort_by: "bumped_at", order: "dsc" }
classified_listing_docs = described_class.search_documents(params: first_page_params)
expect(classified_listing_docs.first["id"]).to eq(classified_listing2.id)
second_page_params = { page: 1, per_page: 1, sort_by: "bumped_at", order: "dsc" }
classified_listing_docs = described_class.search_documents(params: second_page_params)
expect(classified_listing_docs.first["id"]).to eq(classified_listing.id)
end
it "returns an empty Array if no results are found" do
classified_listing.update(category: "forhire")
classified_listing2 = FactoryBot.create(:classified_listing, category: "cfp")
index_documents([classified_listing, classified_listing2])
params = { page: 3, per_page: 1 }
classified_listing_docs = described_class.search_documents(params: params)
expect(classified_listing_docs).to eq([])
end
end
end

View file

@ -0,0 +1,87 @@
require "rails_helper"
RSpec.describe Search::QueryBuilders::ClassifiedListing, 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 TERM_KEYS from params" do
params = { category: "cfp", tags: ["beginner"], contact_via_connect: false }
filter = described_class.new(params)
exepcted_filters = [
{ "term" => { "category" => "cfp" } },
{ "term" => { "tags" => ["beginner"] } },
{ "term" => { "contact_via_connect" => false } },
]
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 = { bumped_at: Time.current, expires_at: 1.day.from_now }
filter = described_class.new(params)
exepcted_filters = [
{ "range" => { "bumped_at" => Time.current } },
{ "range" => { "expires_at" => 1.day.from_now } },
]
expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters)
end
end
it "applies QUERY_KEYS from params" do
params = { classified_listing_search: "test" }
filter = described_class.new(params)
exepcted_query = [{
"simple_query_string" => {
"query" => "test*", "fields" => [:classified_listing_search], "lenient" => true, "analyze_wildcard" => true
}
}]
expect(filter.as_hash.dig("query", "bool", "must")).to match_array(exepcted_query)
end
it "applies QUERY_KEYS, TERM_KEYS, and RANGE_KEYS from params" do
Timecop.freeze(Time.current) do
params = { classified_listing_search: "test", bumped_at: Time.current, category: "cfp" }
filter = described_class.new(params)
exepcted_query = [{
"simple_query_string" => { "query" => "test*", "fields" => [:classified_listing_search], "lenient" => true, "analyze_wildcard" => true }
}]
exepcted_filters = [{ "range" => { "bumped_at" => Time.current } }, { "term" => { "category" => "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", category: "cfp" }
filter = described_class.new(params)
exepcted_filters = [
{ "term" => { "category" => "cfp" } },
]
expect(filter.as_hash.dig("query", "bool", "filter")).to match_array(exepcted_filters)
end
it "sets default params when not present" do
filter = described_class.new({})
expect(filter.as_hash.dig("sort")).to eq("bumped_at" => "desc")
expect(filter.as_hash.dig("size")).to eq(0)
end
it "allows default params to be overriden" do
params = { sort_by: "category", sort_direction: "asc", size: 20 }
filter = described_class.new(params)
expect(filter.as_hash.dig("sort")).to eq("category" => "asc")
expect(filter.as_hash.dig("size")).to eq(20)
end
end
end

View file

@ -0,0 +1,7 @@
# Only included in specs with elasticsearch: true
module ElasticsearchHelpers
def index_documents(resources)
Array.wrap(resources).each(&:index_to_elasticsearch_inline)
described_class.refresh_index
end
end