From 1997e66741e4c2f530779172727e2e072bf6303d Mon Sep 17 00:00:00 2001 From: Molly Struve Date: Thu, 12 Mar 2020 14:57:10 -0400 Subject: [PATCH] Implement User Search backend (#6606) [deploy] --- app/controllers/search_controller.rb | 16 +++++ app/services/search/query_builders/user.rb | 72 +++++++++++++++++++ app/services/search/user.rb | 39 ++++++++++ app/views/service_worker/index.js.erb | 2 + config/routes.rb | 1 + .../search/query_builders/user_spec.rb | 47 ++++++++++++ spec/services/search/user_spec.rb | 28 +++++++- 7 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 app/services/search/query_builders/user.rb create mode 100644 spec/services/search/query_builders/user_spec.rb diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 781d27089..f32af449c 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -11,6 +11,12 @@ class SearchController < ApplicationController tags ].freeze + USER_PARAMS = %i[ + search_fields + page + per_page + ].freeze + def tags tag_docs = Search::Tag.search_documents("name:#{params[:name]}* AND supported:true") @@ -35,6 +41,12 @@ class SearchController < ApplicationController render json: { result: cl_docs } end + def users + user_docs = Search::User.search_documents(params: user_params.to_h) + + render json: { result: user_docs } + end + private def chat_channel_params @@ -54,6 +66,10 @@ class SearchController < ApplicationController params.permit(CLASSIFIED_LISTINGS_PARAMS) end + def user_params + params.permit(USER_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? diff --git a/app/services/search/query_builders/user.rb b/app/services/search/query_builders/user.rb new file mode 100644 index 000000000..4a5c61cc1 --- /dev/null +++ b/app/services/search/query_builders/user.rb @@ -0,0 +1,72 @@ +module Search + module QueryBuilders + class User + QUERY_KEYS = %i[ + search_fields + ].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 + + 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][: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 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 diff --git a/app/services/search/user.rb b/app/services/search/user.rb index 20606c355..04cf97900 100644 --- a/app/services/search/user.rb +++ b/app/services/search/user.rb @@ -3,10 +3,49 @@ module Search INDEX_NAME = "users_#{Rails.env}".freeze INDEX_ALIAS = "users_#{Rails.env}_alias".freeze MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/users.json"), symbolize_names: true).freeze + DEFAULT_PAGE = 0 + DEFAULT_PER_PAGE = 20 class << self + def search_documents(params:) + set_query_size(params) + query_hash = Search::QueryBuilders::User.new(params).as_hash + + results = search(body: query_hash) + hits = results.dig("hits", "hits").map do |user_doc| + prepare_doc(user_doc.dig("_source")) + end + paginate_hits(hits, params) + end + private + def prepare_doc(hit) + { + "user" => { + "username" => hit["username"], + "name" => hit["username"], + "profile_image_90" => hit["profile_image_90"] + }, + "title" => hit["name"], + "path" => hit["path"], + "id" => hit["id"] + } + 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? { diff --git a/app/views/service_worker/index.js.erb b/app/views/service_worker/index.js.erb index d3485892b..348e4c4fa 100644 --- a/app/views/service_worker/index.js.erb +++ b/app/views/service_worker/index.js.erb @@ -118,6 +118,8 @@ // Don't run on search endpoints !url.href.includes('/search/tags') && !url.href.includes('/search/chat_channels') && + !url.href.includes('/search/classified_listings') && + !url.href.includes('/search/users') && caches.match('/shell_top') && // Ensure shell_top is in the cache. caches.match('/shell_bottom')) { // Ensure shell_bottom is in the cache. diff --git a/config/routes.rb b/config/routes.rb index c1b3ae940..397753634 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -220,6 +220,7 @@ Rails.application.routes.draw do get "/search/tags" => "search#tags" get "/search/chat_channels" => "search#chat_channels" get "/search/classified_listings" => "search#classified_listings" + get "/search/users" => "search#users" 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" diff --git a/spec/services/search/query_builders/user_spec.rb b/spec/services/search/query_builders/user_spec.rb new file mode 100644 index 000000000..b988587b8 --- /dev/null +++ b/spec/services/search/query_builders/user_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe Search::QueryBuilders::User, 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 "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: "name", sort_direction: "asc", size: 20 } + filter = described_class.new(params).as_hash + expect(filter.dig("sort")).to eq("name" => "asc") + expect(filter.dig("size")).to eq(20) + end + end +end diff --git a/spec/services/search/user_spec.rb b/spec/services/search/user_spec.rb index 59f12e5af..9334454d7 100644 --- a/spec/services/search/user_spec.rb +++ b/spec/services/search/user_spec.rb @@ -1,9 +1,35 @@ require "rails_helper" -RSpec.describe Search::User, type: :service do +RSpec.describe Search::User, type: :service, elasticsearch: true 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 + let(:user1) { create(:user) } + let(:user2) { create(:user) } + + it "parses user 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(user1).to receive(:available_for).and_return("ruby") + allow(user2).to receive(:employer_name).and_return("Ruby Tuesday") + index_documents([user1, user2]) + query_params = { size: 5, search_fields: "ruby" } + + user_docs = described_class.search_documents(params: query_params) + expect(user_docs.count).to eq(2) + doc_ids = user_docs.map { |t| t.dig("id") } + expect(doc_ids).to include(user1.id, user2.id) + end + end + end end