188 lines
4.9 KiB
Ruby
188 lines
4.9 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
class AccountSearchQueryBuilder
|
||
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/.freeze
|
||
|
||
LANGUAGE = Arel::Nodes.build_quoted('simple').freeze
|
||
EMPTY_STRING = Arel::Nodes.build_quoted('').freeze
|
||
WEIGHT_A = Arel::Nodes.build_quoted('A').freeze
|
||
WEIGHT_B = Arel::Nodes.build_quoted('B').freeze
|
||
WEIGHT_C = Arel::Nodes.build_quoted('C').freeze
|
||
|
||
FIELDS = {
|
||
display_name: { weight: WEIGHT_A }.freeze,
|
||
username: { weight: WEIGHT_B }.freeze,
|
||
domain: { weight: WEIGHT_C, nullable: true }.freeze,
|
||
}.freeze
|
||
|
||
RANK_NORMALIZATION = 32
|
||
|
||
DEFAULT_OPTIONS = {
|
||
limit: 10,
|
||
only_following: false,
|
||
}.freeze
|
||
|
||
# @param [String] terms
|
||
# @param [Hash] options
|
||
# @option [Account] :account
|
||
# @option [Boolean] :only_following
|
||
# @option [Integer] :limit
|
||
# @option [Integer] :offset
|
||
def initialize(terms, options = {})
|
||
@terms = terms
|
||
@options = DEFAULT_OPTIONS.merge(options)
|
||
end
|
||
|
||
# @return [ActiveRecord::Relation]
|
||
def build
|
||
search_scope.tap do |scope|
|
||
scope.merge!(personalization_scope) if with_account?
|
||
|
||
if with_account? && only_following?
|
||
scope.merge!(only_following_scope)
|
||
scope.with!(first_degree_definition) # `merge!` does not handle `with`
|
||
end
|
||
end
|
||
end
|
||
|
||
# @return [Array<Account>]
|
||
def results
|
||
build.to_a
|
||
end
|
||
|
||
private
|
||
|
||
def search_scope
|
||
Account.select(projections)
|
||
.where(match_condition)
|
||
.searchable
|
||
.includes(:account_stat)
|
||
.order(rank: :desc)
|
||
.limit(limit)
|
||
.offset(offset)
|
||
end
|
||
|
||
def personalization_scope
|
||
join_condition = accounts_table.join(follows_table, Arel::Nodes::OuterJoin)
|
||
.on(accounts_table.grouping(accounts_table[:id].eq(follows_table[:account_id]).and(follows_table[:target_account_id].eq(account.id))).or(accounts_table.grouping(accounts_table[:id].eq(follows_table[:target_account_id]).and(follows_table[:account_id].eq(account.id)))))
|
||
.join_sources
|
||
|
||
Account.joins(join_condition)
|
||
.group(accounts_table[:id])
|
||
end
|
||
|
||
def only_following_scope
|
||
Account.where(accounts_table[:id].in(first_degree_table.project('*')))
|
||
end
|
||
|
||
def first_degree_definition
|
||
target_account_ids_query = follows_table.project(follows_table[:target_account_id]).where(follows_table[:account_id].eq(account.id))
|
||
account_id_query = Arel::SelectManager.new.project(account.id)
|
||
|
||
Arel::Nodes::As.new(
|
||
first_degree_table,
|
||
target_account_ids_query.union(:all, account_id_query)
|
||
)
|
||
end
|
||
|
||
def projections
|
||
rank_column = begin
|
||
if with_account?
|
||
weighted_tsrank_template.as('rank')
|
||
else
|
||
tsrank_template.as('rank')
|
||
end
|
||
end
|
||
|
||
[all_columns, rank_column]
|
||
end
|
||
|
||
def all_columns
|
||
accounts_table[Arel.star]
|
||
end
|
||
|
||
def match_condition
|
||
Arel::Nodes::InfixOperation.new('@@', tsvector_template, tsquery_template)
|
||
end
|
||
|
||
def tsrank_template
|
||
@tsrank_template ||= Arel::Nodes::NamedFunction.new('ts_rank_cd', [tsvector_template, tsquery_template, RANK_NORMALIZATION])
|
||
end
|
||
|
||
def weighted_tsrank_template
|
||
@weighted_tsrank_template ||= Arel::Nodes::Multiplication.new(weight, tsrank_template)
|
||
end
|
||
|
||
def weight
|
||
Arel::Nodes::Addition.new(follows_table[:id].count, 1)
|
||
end
|
||
|
||
def tsvector_template
|
||
return @tsvector_template if defined?(@tsvector_template)
|
||
|
||
vectors = FIELDS.keys.map do |column|
|
||
options = FIELDS[column]
|
||
|
||
vector = accounts_table[column]
|
||
vector = Arel::Nodes::NamedFunction.new('coalesce', [vector, EMPTY_STRING]) if options[:nullable]
|
||
vector = Arel::Nodes::NamedFunction.new('to_tsvector', [LANGUAGE, vector])
|
||
|
||
Arel::Nodes::NamedFunction.new('setweight', [vector, options[:weight]])
|
||
end
|
||
|
||
@tsvector_template = Arel::Nodes::Grouping.new(vectors.reduce { |memo, vector| Arel::Nodes::Concat.new(memo, vector) })
|
||
end
|
||
|
||
def query_vector
|
||
@query_vector ||= Arel::Nodes::NamedFunction.new('to_tsquery', [LANGUAGE, tsquery_template])
|
||
end
|
||
|
||
def sanitized_terms
|
||
@sanitized_terms ||= @terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
|
||
end
|
||
|
||
def tsquery_template
|
||
return @tsquery_template if defined?(@tsquery_template)
|
||
|
||
terms = [
|
||
Arel::Nodes.build_quoted("' "),
|
||
Arel::Nodes.build_quoted(sanitized_terms),
|
||
Arel::Nodes.build_quoted(" '"),
|
||
Arel::Nodes.build_quoted(':*'),
|
||
]
|
||
|
||
@tsquery_template = Arel::Nodes::NamedFunction.new('to_tsquery', [LANGUAGE, terms.reduce { |memo, term| Arel::Nodes::Concat.new(memo, term) }])
|
||
end
|
||
|
||
def account
|
||
@options[:account]
|
||
end
|
||
|
||
def with_account?
|
||
account.present?
|
||
end
|
||
|
||
def limit
|
||
@options[:limit]
|
||
end
|
||
|
||
def offset
|
||
@options[:offset]
|
||
end
|
||
|
||
def only_following?
|
||
@options[:only_following]
|
||
end
|
||
|
||
def accounts_table
|
||
Account.arel_table
|
||
end
|
||
|
||
def follows_table
|
||
Follow.arel_table
|
||
end
|
||
|
||
def first_degree_table
|
||
Arel::Table.new(:first_degree)
|
||
end
|
||
end
|