Implement Search for Chat Channels with Elasticsearch (#6282) [deploy]

This commit is contained in:
Molly Struve 2020-02-26 11:06:38 -05:00 committed by GitHub
parent fc09716709
commit 8f0bfa5e08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 172 additions and 42 deletions

View file

@ -8,4 +8,28 @@ class SearchController < ApplicationController
rescue Search::Errors::Transport::BadRequest
render json: { result: [] }
end
def chat_channels
ccm_docs = Search::ChatChannelMembership.search_documents(
params: chat_channel_params.to_h, user_id: current_user.id,
)
render json: { result: ccm_docs }
end
private
def chat_channel_params
accessible = %i[
per_page
page
channel_text
channel_type
channel_status
status
]
params[:page] = params[:page].to_i if params[:page].present?
params[:per_page] = params[:per_page].to_i if params[:per_page].present?
params.permit(accessible)
end
end

View file

@ -104,38 +104,49 @@ export function getChannels(
successCb,
_failureCb,
) {
const client = algoliasearch(props.algoliaId, props.algoliaKey);
const index = client.initIndex(props.algoliaIndex);
const filters = {
...{
hitsPerPage: 30 + paginationNumber,
page: paginationNumber,
const dataHash = {};
if (additionalFilters.filters) {
const [key, value] = additionalFilters.filters.split(':');
dataHash[key] = value;
}
dataHash.per_page = 30;
dataHash.page = paginationNumber;
dataHash.channel_text = query;
const searchParams = new URLSearchParams(dataHash).toString();
return fetch(`/search/chat_channels?${searchParams}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
...additionalFilters,
};
index.search(query, filters).then(content => {
const channels = content.hits;
if (
retrievalID === null ||
content.hits.filter(e => e.chat_channel_id === retrievalID).length === 1
) {
successCb(channels, query);
} else {
fetch(
`/chat_channel_memberships/find_by_chat_channel_id?chat_channel_id=${retrievalID}`,
{
Accept: 'application/json',
'Content-Type': 'application/json',
credentials: 'same-origin',
},
)
.then(response => response.json())
.then(json => {
channels.unshift(json);
successCb(channels, query);
});
}
});
credentials: 'same-origin',
})
.then(response => response.json())
.then(response => {
const channels = response.result;
if (
retrievalID === null ||
channels.filter(e => e.chat_channel_id === retrievalID).length === 1
) {
successCb(channels, query);
} else {
fetch(
`/chat_channel_memberships/find_by_chat_channel_id?chat_channel_id=${retrievalID}`,
{
Accept: 'application/json',
'Content-Type': 'application/json',
credentials: 'same-origin',
},
)
.then(individualResponse => individualResponse.json())
.then(json => {
channels.unshift(json);
successCb(channels, query);
});
}
});
}
export function getUnopenedChannelIds(successCb) {

View file

@ -37,7 +37,8 @@ class ChatChannelMembership < ApplicationRecord
end
def channel_text
"#{chat_channel.channel_name} #{chat_channel.slug} #{chat_channel.channel_human_names}"
parsed_channel_name = chat_channel.channel_name&.gsub("chat between", "")&.gsub("and", "")
"#{parsed_channel_name} #{chat_channel.slug} #{chat_channel.channel_human_names.join(' ')}"
end
def channel_name

View file

@ -3,12 +3,17 @@ module Search
INDEX_NAME = "chat_channel_memberships_#{Rails.env}".freeze
INDEX_ALIAS = "chat_channel_memberships_#{Rails.env}_alias".freeze
MAPPINGS = JSON.parse(File.read("config/elasticsearch/mappings/chat_channel_memberships.json"), symbolize_names: true).freeze
DEFAULT_PAGE = 0
DEFAULT_PER_PAGE = 30
class << self
def search_documents(params:, user_id:)
set_query_size(params)
query_hash = Search::QueryBuilders::ChatChannelMembership.new(params, user_id).as_hash
results = search(body: query_hash)
results.dig("hits", "hits").map { |ccm_doc| ccm_doc.dig("_source") }
hits = results.dig("hits", "hits").map { |ccm_doc| ccm_doc.dig("_source") }
paginate_hits(hits, params)
end
private
@ -17,6 +22,19 @@ module Search
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] + 1) * params[:page]
hits[start, params[:per_page]]
end
def index_settings
if Rails.env.production?
{

View file

@ -36,14 +36,13 @@ module Search
@body = ActiveSupport::HashWithIndifferentAccess.new
build_queries
add_sort
# By default we will return 0 documents if size is not specified
@body[:size] = @params[:size] || DEFAULT_PARAMS[:size]
set_size
end
def build_queries
@body[:query] = {}
@body[:query][:bool] = { filter: filter_conditions }
@body[:query][:bool] = { must: query_conditions } if query_keys_present?
@body[:query][:bool][:must] = query_conditions if query_keys_present?
end
def add_sort
@ -54,9 +53,14 @@ module Search
}
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_KEYS.map do |filter_key|
next if @params[filter_key].blank?
next if @params[filter_key].blank? || @params[filter_key] == "all"
{ term: { filter_key => @params[filter_key] } }
end.compact
@ -70,7 +74,14 @@ module Search
QUERY_KEYS.map do |query_key|
next if @params[query_key].blank?
{ match: { query_key => { query: @params[query_key] } } }
{
simple_query_string: {
query: "#{@params[query_key]}*",
fields: [query_key],
lenient: true,
analyze_wildcard: true
}
}
end.compact
end
end

View file

@ -216,6 +216,7 @@ Rails.application.routes.draw do
resolve("ProMembership") { [:pro_membership] } # see https://guides.rubyonrails.org/routing.html#using-resolve
get "/search/tags" => "search#tags"
get "/search/chat_channels" => "search#chat_channels"
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

@ -3,6 +3,15 @@ require "rails_helper"
RSpec.describe ChatChannelMembership, type: :model do
let(:chat_channel_membership) { FactoryBot.create(:chat_channel_membership) }
describe "#channel_text" do
it "sets channel text using name, slig, and human names" do
chat_channel = chat_channel_membership.chat_channel
parsed_channel_name = chat_channel_membership.channel_name&.gsub("chat between", "")&.gsub("and", "")
expected_text = "#{parsed_channel_name} #{chat_channel.slug} #{chat_channel.channel_human_names.join(' ')}"
expect(chat_channel_membership.channel_text).to eq(expected_text)
end
end
describe "#index_to_elasticsearch" do
it "enqueues job to index tag to elasticsearch" do
sidekiq_assert_enqueued_with(job: Search::IndexToElasticsearchWorker, args: [described_class.to_s, chat_channel_membership.id]) do

View file

@ -23,4 +23,20 @@ RSpec.describe "Search", type: :request, proper_status: true do
expect(response.parsed_body).to eq("result" => [])
end
end
describe "GET /search/chat_channels" do
let(:authorized_user) { create(:user) }
let(:mock_documents) do
[{ "channel_name" => "channel1" }]
end
it "returns json" do
sign_in authorized_user
allow(Search::ChatChannelMembership).to receive(:search_documents).and_return(
mock_documents,
)
get "/search/chat_channels"
expect(response.parsed_body).to eq("result" => mock_documents)
end
end
end

View file

@ -171,6 +171,22 @@ RSpec.describe Search::ChatChannelMembership, type: :service, elasticsearch: tru
end
end
context "with a query and filter" do
it "searches by channel_text and status" do
allow(chat_channel_membership1).to receive(:channel_text).and_return("a name")
allow(chat_channel_membership2).to receive(:channel_text).and_return("another name")
chat_channel_membership1.update(status: "active")
chat_channel_membership2.update(status: "inactive")
index_documents([chat_channel_membership1, chat_channel_membership2])
name_params = { size: 5, channel_text: "name", status: "active" }
chat_channel_membership_docs = described_class.search_documents(params: name_params, user_id: user.id)
expect(chat_channel_membership_docs.count).to eq(1)
doc_ids = chat_channel_membership_docs.map { |t| t.dig("id") }
expect(doc_ids).to include(chat_channel_membership1.id)
end
end
it "sorts documents for given field" do
chat_channel_membership1.update(status: "inactive")
chat_channel_membership2.update(status: "active")
@ -194,5 +210,13 @@ RSpec.describe Search::ChatChannelMembership, type: :service, elasticsearch: tru
expect(chat_channel_membership_docs.first["id"]).to eq(chat_channel_membership1.id)
expect(chat_channel_membership_docs.last["id"]).to eq(chat_channel_membership2.id)
end
it "will return a set number of docs based on pagination params" do
index_documents([chat_channel_membership1, chat_channel_membership2])
params = { page: 0, per_page: 1 }
chat_channel_membership_docs = described_class.search_documents(params: params, user_id: user.id)
expect(chat_channel_membership_docs.count).to eq(1)
end
end
end

View file

@ -30,10 +30,25 @@ RSpec.describe Search::QueryBuilders::ChatChannelMembership, type: :service do
it "applies QUERY_KEYS from params" do
params = { channel_text: "a_name" }
query = described_class.new(params, 1)
expected_musts = [
{ "match" => { "channel_text" => { "query" => "a_name" } } },
]
expect(query.as_hash.dig("query", "bool", "must")).to match_array(expected_musts)
expected_query = [{
"simple_query_string" => {
"query" => "a_name*", "fields" => [:channel_text], "lenient" => true, "analyze_wildcard" => true
}
}]
expect(query.as_hash.dig("query", "bool", "must")).to match_array(expected_query)
end
it "applies QUERY_KEYS and FILTER_KEYS from params" do
params = { channel_text: "a_name", channel_status: "active" }
query = described_class.new(params, 1)
expected_query = [{
"simple_query_string" => {
"query" => "a_name*", "fields" => [:channel_text], "lenient" => true, "analyze_wildcard" => true
}
}]
expected_filters = [{ "term" => { "channel_status" => "active" } }, { "term" => { "viewable_by" => 1 } }]
expect(query.as_hash.dig("query", "bool", "must")).to match_array(expected_query)
expect(query.as_hash.dig("query", "bool", "filter")).to match_array(expected_filters)
end
it "ignores params we dont support" do