forked from fedi/mastodon
Extract AccountSearch concern from Account (#24716)
This commit is contained in:
parent
f1c1dd0118
commit
38b2974a83
|
@ -1994,7 +1994,6 @@ Style/HashAsLastArrayItem:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/admin/statuses_controller.rb'
|
- 'app/controllers/admin/statuses_controller.rb'
|
||||||
- 'app/controllers/api/v1/statuses_controller.rb'
|
- 'app/controllers/api/v1/statuses_controller.rb'
|
||||||
- 'app/models/account.rb'
|
|
||||||
- 'app/models/concerns/account_counters.rb'
|
- 'app/models/concerns/account_counters.rb'
|
||||||
- 'app/models/concerns/status_threading_concern.rb'
|
- 'app/models/concerns/status_threading_concern.rb'
|
||||||
- 'app/models/status.rb'
|
- 'app/models/status.rb'
|
||||||
|
@ -2068,7 +2067,6 @@ Style/MapToHash:
|
||||||
# SupportedStyles: literals, strict
|
# SupportedStyles: literals, strict
|
||||||
Style/MutableConstant:
|
Style/MutableConstant:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/models/account.rb'
|
|
||||||
- 'app/models/tag.rb'
|
- 'app/models/tag.rb'
|
||||||
- 'app/services/delete_account_service.rb'
|
- 'app/services/delete_account_service.rb'
|
||||||
- 'config/initializers/twitter_regex.rb'
|
- 'config/initializers/twitter_regex.rb'
|
||||||
|
|
|
@ -78,6 +78,7 @@ class Account < ApplicationRecord
|
||||||
include DomainNormalizable
|
include DomainNormalizable
|
||||||
include DomainMaterializable
|
include DomainMaterializable
|
||||||
include AccountMerging
|
include AccountMerging
|
||||||
|
include AccountSearch
|
||||||
|
|
||||||
enum protocol: { ostatus: 0, activitypub: 1 }
|
enum protocol: { ostatus: 0, activitypub: 1 }
|
||||||
enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
|
enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
|
||||||
|
@ -410,14 +411,6 @@ class Account < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/
|
|
||||||
TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
|
|
||||||
|
|
||||||
REPUTATION_SCORE_FUNCTION = '(greatest(0, coalesce(s.followers_count, 0)) / (greatest(0, coalesce(s.following_count, 0)) + 1.0))'
|
|
||||||
FOLLOWERS_SCORE_FUNCTION = 'log(greatest(0, coalesce(s.followers_count, 0)) + 2)'
|
|
||||||
TIME_DISTANCE_FUNCTION = '(case when s.last_status_at is null then 0 else exp(-1.0 * ((greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) / (2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))))) end)'
|
|
||||||
BOOST = "((#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0)"
|
|
||||||
|
|
||||||
def readonly_attributes
|
def readonly_attributes
|
||||||
super - %w(statuses_count following_count followers_count)
|
super - %w(statuses_count following_count followers_count)
|
||||||
end
|
end
|
||||||
|
@ -427,37 +420,6 @@ class Account < ApplicationRecord
|
||||||
DeliveryFailureTracker.without_unavailable(urls)
|
DeliveryFailureTracker.without_unavailable(urls)
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_for(terms, limit: 10, offset: 0)
|
|
||||||
tsquery = generate_query_for_search(terms)
|
|
||||||
|
|
||||||
sql = <<-SQL.squish
|
|
||||||
SELECT
|
|
||||||
accounts.*,
|
|
||||||
#{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
|
||||||
FROM accounts
|
|
||||||
LEFT JOIN users ON accounts.id = users.account_id
|
|
||||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
|
||||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
|
||||||
AND accounts.suspended_at IS NULL
|
|
||||||
AND accounts.moved_to_account_id IS NULL
|
|
||||||
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
|
||||||
ORDER BY rank DESC
|
|
||||||
LIMIT :limit OFFSET :offset
|
|
||||||
SQL
|
|
||||||
|
|
||||||
records = find_by_sql([sql, limit: limit, offset: offset, tsquery: tsquery])
|
|
||||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
|
||||||
records
|
|
||||||
end
|
|
||||||
|
|
||||||
def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
|
|
||||||
tsquery = generate_query_for_search(terms)
|
|
||||||
sql = advanced_search_for_sql_template(following)
|
|
||||||
records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
|
|
||||||
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
|
||||||
records
|
|
||||||
end
|
|
||||||
|
|
||||||
def from_text(text)
|
def from_text(text)
|
||||||
return [] if text.blank?
|
return [] if text.blank?
|
||||||
|
|
||||||
|
@ -471,64 +433,6 @@ class Account < ApplicationRecord
|
||||||
EntityCache.instance.mention(username, domain)
|
EntityCache.instance.mention(username, domain)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_query_for_search(unsanitized_terms)
|
|
||||||
terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
|
|
||||||
|
|
||||||
# The final ":*" is for prefix search.
|
|
||||||
# The trailing space does not seem to fit any purpose, but `to_tsquery`
|
|
||||||
# behaves differently with and without a leading space if the terms start
|
|
||||||
# with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
|
|
||||||
# the same query.
|
|
||||||
"' #{terms} ':*"
|
|
||||||
end
|
|
||||||
|
|
||||||
def advanced_search_for_sql_template(following)
|
|
||||||
if following
|
|
||||||
<<-SQL.squish
|
|
||||||
WITH first_degree AS (
|
|
||||||
SELECT target_account_id
|
|
||||||
FROM follows
|
|
||||||
WHERE account_id = :id
|
|
||||||
UNION ALL
|
|
||||||
SELECT :id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
accounts.*,
|
|
||||||
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
|
|
||||||
FROM accounts
|
|
||||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
|
|
||||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
|
||||||
WHERE accounts.id IN (SELECT * FROM first_degree)
|
|
||||||
AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
|
||||||
AND accounts.suspended_at IS NULL
|
|
||||||
AND accounts.moved_to_account_id IS NULL
|
|
||||||
GROUP BY accounts.id, s.id
|
|
||||||
ORDER BY rank DESC
|
|
||||||
LIMIT :limit OFFSET :offset
|
|
||||||
SQL
|
|
||||||
else
|
|
||||||
<<-SQL.squish
|
|
||||||
SELECT
|
|
||||||
accounts.*,
|
|
||||||
#{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank,
|
|
||||||
count(f.id) AS followed
|
|
||||||
FROM accounts
|
|
||||||
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
|
|
||||||
LEFT JOIN users ON accounts.id = users.account_id
|
|
||||||
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
|
||||||
WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
|
|
||||||
AND accounts.suspended_at IS NULL
|
|
||||||
AND accounts.moved_to_account_id IS NULL
|
|
||||||
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
|
||||||
GROUP BY accounts.id, s.id
|
|
||||||
ORDER BY followed DESC, rank DESC
|
|
||||||
LIMIT :limit OFFSET :offset
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def emojis
|
def emojis
|
||||||
|
|
140
app/models/concerns/account_search.rb
Normal file
140
app/models/concerns/account_search.rb
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module AccountSearch
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/
|
||||||
|
|
||||||
|
TEXT_SEARCH_RANKS = <<~SQL.squish
|
||||||
|
(
|
||||||
|
setweight(to_tsvector('simple', accounts.display_name), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', accounts.username), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C')
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
REPUTATION_SCORE_FUNCTION = <<~SQL.squish
|
||||||
|
(
|
||||||
|
greatest(0, coalesce(s.followers_count, 0)) / (
|
||||||
|
greatest(0, coalesce(s.following_count, 0)) + 1.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
FOLLOWERS_SCORE_FUNCTION = <<~SQL.squish
|
||||||
|
log(
|
||||||
|
greatest(0, coalesce(s.followers_count, 0)) + 2
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
TIME_DISTANCE_FUNCTION = <<~SQL.squish
|
||||||
|
(
|
||||||
|
case
|
||||||
|
when s.last_status_at is null then 0
|
||||||
|
else exp(
|
||||||
|
-1.0 * (
|
||||||
|
(
|
||||||
|
greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) /#{' '}
|
||||||
|
(2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
BOOST = <<~SQL.squish
|
||||||
|
(
|
||||||
|
(#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
|
||||||
|
BASIC_SEARCH_SQL = <<~SQL.squish
|
||||||
|
SELECT
|
||||||
|
accounts.*,
|
||||||
|
#{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||||
|
FROM accounts
|
||||||
|
LEFT JOIN users ON accounts.id = users.account_id
|
||||||
|
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||||
|
WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
|
||||||
|
AND accounts.suspended_at IS NULL
|
||||||
|
AND accounts.moved_to_account_id IS NULL
|
||||||
|
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL
|
||||||
|
|
||||||
|
ADVANCED_SEARCH_WITH_FOLLOWING = <<~SQL.squish
|
||||||
|
WITH first_degree AS (
|
||||||
|
SELECT target_account_id
|
||||||
|
FROM follows
|
||||||
|
WHERE account_id = :id
|
||||||
|
UNION ALL
|
||||||
|
SELECT :id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
accounts.*,
|
||||||
|
(count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
|
||||||
|
FROM accounts
|
||||||
|
LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
|
||||||
|
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||||
|
WHERE accounts.id IN (SELECT * FROM first_degree)
|
||||||
|
AND to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
|
||||||
|
AND accounts.suspended_at IS NULL
|
||||||
|
AND accounts.moved_to_account_id IS NULL
|
||||||
|
GROUP BY accounts.id, s.id
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL
|
||||||
|
|
||||||
|
ADVANCED_SEARCH_WITHOUT_FOLLOWING = <<~SQL.squish
|
||||||
|
SELECT
|
||||||
|
accounts.*,
|
||||||
|
#{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank,
|
||||||
|
count(f.id) AS followed
|
||||||
|
FROM accounts
|
||||||
|
LEFT OUTER JOIN follows AS f ON
|
||||||
|
(accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
|
||||||
|
LEFT JOIN users ON accounts.id = users.account_id
|
||||||
|
LEFT JOIN account_stats AS s ON accounts.id = s.account_id
|
||||||
|
WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
|
||||||
|
AND accounts.suspended_at IS NULL
|
||||||
|
AND accounts.moved_to_account_id IS NULL
|
||||||
|
AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
|
||||||
|
GROUP BY accounts.id, s.id
|
||||||
|
ORDER BY followed DESC, rank DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
SQL
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def search_for(terms, limit: 10, offset: 0)
|
||||||
|
tsquery = generate_query_for_search(terms)
|
||||||
|
|
||||||
|
find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||||
|
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
|
||||||
|
tsquery = generate_query_for_search(terms)
|
||||||
|
sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
|
||||||
|
|
||||||
|
find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
|
||||||
|
ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_query_for_search(unsanitized_terms)
|
||||||
|
terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
|
||||||
|
|
||||||
|
# The final ":*" is for prefix search.
|
||||||
|
# The trailing space does not seem to fit any purpose, but `to_tsquery`
|
||||||
|
# behaves differently with and without a leading space if the terms start
|
||||||
|
# with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
|
||||||
|
# the same query.
|
||||||
|
"' #{terms} ':*"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue