mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 10:57:04 +08:00
fb3c610f09
Sites with many categories and many of them in muted by default (see `default_categories_muted`) reported bad performance when requesting the homepage as an anonymous user. This was the case because of the long query that iterated over topics and categories trying to remove those from the muted categories.
1206 lines
37 KiB
Ruby
1206 lines
37 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Helps us find topics.
|
|
# Returns a TopicList object containing the topics found.
|
|
#
|
|
|
|
class TopicQuery
|
|
include PrivateMessageLists
|
|
|
|
PG_MAX_INT ||= 2_147_483_647
|
|
DEFAULT_PER_PAGE_COUNT ||= 30
|
|
|
|
def self.validators
|
|
@validators ||=
|
|
begin
|
|
int = lambda { |x| Integer === x || (String === x && x.match?(/\A-?[0-9]+\z/)) }
|
|
zero_up_to_max_int = lambda { |x| int.call(x) && x.to_i.between?(0, PG_MAX_INT) }
|
|
array_or_string = lambda { |x| Array === x || String === x }
|
|
|
|
{
|
|
max_posts: zero_up_to_max_int,
|
|
min_posts: zero_up_to_max_int,
|
|
page: zero_up_to_max_int,
|
|
tags: array_or_string,
|
|
}
|
|
end
|
|
end
|
|
|
|
def self.validate?(option, value)
|
|
if fn = validators[option.to_sym]
|
|
fn.call(value)
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
def self.public_valid_options
|
|
# For these to work in Ember, add them to `controllers/discovery-sortable.js`
|
|
@public_valid_options ||= %i[
|
|
page
|
|
before
|
|
bumped_before
|
|
topic_ids
|
|
category
|
|
order
|
|
ascending
|
|
min_posts
|
|
max_posts
|
|
status
|
|
filter
|
|
state
|
|
search
|
|
q
|
|
f
|
|
group_name
|
|
tags
|
|
match_all_tags
|
|
no_subcategories
|
|
no_tags
|
|
exclude_tag
|
|
]
|
|
end
|
|
|
|
def self.valid_options
|
|
@valid_options ||=
|
|
public_valid_options +
|
|
%i[
|
|
except_topic_ids
|
|
limit
|
|
page
|
|
per_page
|
|
visible
|
|
guardian
|
|
no_definitions
|
|
destination_category_id
|
|
include_all_pms
|
|
include_pms
|
|
]
|
|
end
|
|
|
|
# Maps `order` to a columns in `topics`
|
|
SORTABLE_MAPPING = {
|
|
"likes" => "like_count",
|
|
"op_likes" => "op_likes",
|
|
"views" => "views",
|
|
"posts" => "posts_count",
|
|
"activity" => "bumped_at",
|
|
"posters" => "participant_count",
|
|
"category" => "category_id",
|
|
"created" => "created_at",
|
|
}
|
|
|
|
cattr_accessor :results_filter_callbacks
|
|
self.results_filter_callbacks = []
|
|
|
|
attr_accessor :options, :user, :guardian
|
|
|
|
def self.add_custom_filter(key, &blk)
|
|
@custom_filters ||= {}
|
|
valid_options << key
|
|
public_valid_options << key
|
|
@custom_filters[key] = blk
|
|
end
|
|
|
|
def self.remove_custom_filter(key)
|
|
@custom_filters.delete(key)
|
|
public_valid_options.delete(key)
|
|
valid_options.delete(key)
|
|
@custom_filters = nil if @custom_filters.length == 0
|
|
end
|
|
|
|
def self.apply_custom_filters(results, topic_query)
|
|
if @custom_filters
|
|
@custom_filters.each { |key, filter| results = filter.call(results, topic_query) }
|
|
end
|
|
results
|
|
end
|
|
|
|
def initialize(user = nil, options = {})
|
|
options.assert_valid_keys(TopicQuery.valid_options)
|
|
@options = options.dup
|
|
@user = user
|
|
@guardian = options[:guardian] || Guardian.new(@user)
|
|
end
|
|
|
|
def joined_topic_user(list = nil)
|
|
(list || Topic).joins(
|
|
"LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{@user.id.to_i})",
|
|
)
|
|
end
|
|
|
|
def get_pm_params(topic)
|
|
if topic.private_message?
|
|
my_group_ids =
|
|
topic
|
|
.topic_allowed_groups
|
|
.joins(
|
|
"
|
|
LEFT JOIN group_users gu
|
|
ON topic_allowed_groups.group_id = gu.group_id
|
|
AND gu.user_id = #{@user.id.to_i}
|
|
",
|
|
)
|
|
.where("gu.group_id IS NOT NULL")
|
|
.pluck(:group_id)
|
|
|
|
target_group_ids = topic.topic_allowed_groups.pluck(:group_id)
|
|
|
|
target_users = topic.topic_allowed_users
|
|
|
|
if my_group_ids.present?
|
|
# strip out users in groups you already belong to
|
|
target_users =
|
|
target_users.joins(
|
|
"LEFT JOIN group_users gu ON gu.user_id = topic_allowed_users.user_id AND #{DB.sql_fragment("gu.group_id IN (?)", my_group_ids)}",
|
|
).where("gu.group_id IS NULL")
|
|
end
|
|
|
|
target_user_ids =
|
|
target_users.where("NOT topic_allowed_users.user_id = ?", @user.id).pluck(:user_id)
|
|
|
|
{
|
|
topic: topic,
|
|
my_group_ids: my_group_ids,
|
|
target_group_ids: target_group_ids,
|
|
target_user_ids: target_user_ids,
|
|
}
|
|
end
|
|
end
|
|
|
|
def list_related_for(topic, pm_params: nil)
|
|
return if !topic.private_message?
|
|
return if @user.blank?
|
|
|
|
return if !@user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map)
|
|
|
|
builder = SuggestedTopicsBuilder.new(topic)
|
|
pm_params = pm_params || get_pm_params(topic)
|
|
|
|
if pm_params[:my_group_ids].present?
|
|
builder.add_results(
|
|
related_messages_group(
|
|
pm_params.merge(
|
|
count: [6, builder.results_left].max,
|
|
exclude: builder.excluded_topic_ids,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
builder.add_results(
|
|
related_messages_user(
|
|
pm_params.merge(
|
|
count: [6, builder.results_left].max,
|
|
exclude: builder.excluded_topic_ids,
|
|
),
|
|
),
|
|
)
|
|
end
|
|
|
|
params = { unordered: true }
|
|
params[:preload_posters] = true
|
|
create_list(:suggested, params, builder.results)
|
|
end
|
|
|
|
# Return a list of suggested topics for a topic
|
|
def list_suggested_for(topic, pm_params: nil)
|
|
# Don't suggest messages unless we have a user, and private messages are
|
|
# enabled.
|
|
if topic.private_message? &&
|
|
(@user.blank? || !@user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map))
|
|
return
|
|
end
|
|
|
|
builder = SuggestedTopicsBuilder.new(topic)
|
|
|
|
pm_params = pm_params || get_pm_params(topic)
|
|
|
|
if DiscoursePluginRegistry.list_suggested_for_providers.any?
|
|
DiscoursePluginRegistry.list_suggested_for_providers.each do |provider|
|
|
suggested = provider.call(topic, pm_params, self)
|
|
builder.add_results(suggested[:result]) if suggested && !suggested[:result].blank?
|
|
end
|
|
end
|
|
|
|
# When logged in we start with different results
|
|
if @user
|
|
if topic.private_message?
|
|
unless builder.full?
|
|
builder.add_results(new_messages(pm_params.merge(count: builder.results_left)))
|
|
end
|
|
|
|
unless builder.full?
|
|
builder.add_results(unread_messages(pm_params.merge(count: builder.results_left)))
|
|
end
|
|
else
|
|
builder.add_results(
|
|
unread_results(
|
|
topic: topic,
|
|
per_page: builder.results_left,
|
|
max_age: SiteSetting.suggested_topics_unread_max_days_old,
|
|
),
|
|
:high,
|
|
)
|
|
|
|
unless builder.full?
|
|
builder.add_results(new_results(topic: topic, per_page: builder.category_results_left))
|
|
end
|
|
end
|
|
end
|
|
|
|
if !topic.private_message?
|
|
unless builder.full?
|
|
builder.add_results(
|
|
random_suggested(topic, builder.results_left, builder.excluded_topic_ids),
|
|
)
|
|
end
|
|
end
|
|
|
|
params = { unordered: true }
|
|
params[:preload_posters] = true if topic.private_message?
|
|
create_list(:suggested, params, builder.results)
|
|
end
|
|
|
|
# The latest view of topics
|
|
def list_latest
|
|
create_list(:latest, {}, latest_results)
|
|
end
|
|
|
|
def list_filter
|
|
list_latest
|
|
end
|
|
|
|
def list_read
|
|
create_list(:read, unordered: true) do |topics|
|
|
topics.where("tu.last_visited_at IS NOT NULL").order("tu.last_visited_at DESC")
|
|
end
|
|
end
|
|
|
|
def list_new
|
|
if @user&.new_new_view_enabled?
|
|
create_list(:new, { unordered: true }, new_and_unread_results)
|
|
else
|
|
create_list(:new, { unordered: true }, new_results)
|
|
end
|
|
end
|
|
|
|
def list_unread
|
|
create_list(:unread, { unordered: true }, unread_results)
|
|
end
|
|
|
|
def list_unseen
|
|
create_list(:unseen, { unordered: true }, unseen_results)
|
|
end
|
|
|
|
def list_posted
|
|
create_list(:posted) { |l| l.where("tu.posted") }
|
|
end
|
|
|
|
def list_bookmarks
|
|
create_list(:bookmarks) { |l| l.where("tu.bookmarked") }
|
|
end
|
|
|
|
def list_top_for(period)
|
|
score_column = TopTopic.score_column_for_period(period)
|
|
create_list(:top, unordered: true) do |topics|
|
|
topics = remove_muted_categories(topics, @user)
|
|
topics = topics.joins(:top_topic).where("top_topics.#{score_column} > 0")
|
|
if period == :yearly && @user.try(:trust_level) == TrustLevel[0]
|
|
topics.order(<<~SQL)
|
|
CASE WHEN (
|
|
COALESCE(topics.pinned_at, '1900-01-01') > COALESCE(tu.cleared_pinned_at, '1900-01-01')
|
|
) THEN 0 ELSE 1 END,
|
|
top_topics.#{score_column} DESC,
|
|
topics.bumped_at DESC
|
|
SQL
|
|
else
|
|
topics.order(<<~SQL)
|
|
COALESCE(top_topics.#{score_column}, 0) DESC, topics.bumped_at DESC
|
|
SQL
|
|
end
|
|
end
|
|
end
|
|
|
|
def list_topics_by(user)
|
|
@options[:filtered_to_user] = user.id
|
|
create_list(:user_topics) { |topics| topics.where(user_id: user.id) }
|
|
end
|
|
|
|
def list_group_topics(group)
|
|
list =
|
|
default_results.where(
|
|
"
|
|
topics.user_id IN (
|
|
SELECT user_id FROM group_users gu WHERE gu.group_id = ?
|
|
)
|
|
",
|
|
group.id.to_i,
|
|
)
|
|
|
|
create_list(:group_topics, {}, list)
|
|
end
|
|
|
|
def list_category_topic_ids(category)
|
|
query = default_results(category: category.id)
|
|
pinned_ids =
|
|
query
|
|
.where("topics.pinned_at IS NOT NULL AND topics.category_id = ?", category.id)
|
|
.limit(nil)
|
|
.order("pinned_at DESC")
|
|
.pluck(:id)
|
|
non_pinned_ids =
|
|
query.where("topics.pinned_at IS NULL OR topics.category_id <> ?", category.id).pluck(:id)
|
|
(pinned_ids + non_pinned_ids)
|
|
end
|
|
|
|
def list_new_in_category(category)
|
|
create_list(:new_in_category, unordered: true, category: category.id) do |list|
|
|
list.by_newest.first(25)
|
|
end
|
|
end
|
|
|
|
def self.new_filter(list, treat_as_new_topic_start_date: nil, treat_as_new_topic_clause_sql: nil)
|
|
if treat_as_new_topic_start_date
|
|
list =
|
|
list.where("topics.created_at >= :created_at", created_at: treat_as_new_topic_start_date)
|
|
else
|
|
list = list.where("topics.created_at >= #{treat_as_new_topic_clause_sql}")
|
|
end
|
|
|
|
list.where("tu.last_read_post_number IS NULL").where(
|
|
"COALESCE(tu.notification_level, :tracking) >= :tracking",
|
|
tracking: TopicUser.notification_levels[:tracking],
|
|
)
|
|
end
|
|
|
|
def self.unread_filter(list, whisperer: false)
|
|
col_name = whisperer ? "highest_staff_post_number" : "highest_post_number"
|
|
|
|
list.where("tu.last_read_post_number < topics.#{col_name}").where(
|
|
"COALESCE(tu.notification_level, :regular) >= :tracking",
|
|
regular: TopicUser.notification_levels[:regular],
|
|
tracking: TopicUser.notification_levels[:tracking],
|
|
)
|
|
end
|
|
|
|
# Any changes here will need to be reflected in `lib/topic-list-tracked-filter.js` for the `isTrackedTopic` function on
|
|
# the client side. The `f=tracked` query param is not heavily used so we do not want to be querying for a topic's
|
|
# tracked status by default. Instead, the client will handle the filtering when the `f=tracked` query params is present.
|
|
def self.tracked_filter(list, user_id)
|
|
tracked_category_ids_sql = <<~SQL
|
|
SELECT cd.category_id FROM category_users cd
|
|
WHERE cd.user_id = :user_id AND cd.notification_level >= :tracking
|
|
SQL
|
|
|
|
has_sub_sub_categories = SiteSetting.max_category_nesting == 3
|
|
|
|
sql = +<<~SQL
|
|
topics.category_id IN (
|
|
SELECT
|
|
c.id
|
|
FROM categories c
|
|
#{has_sub_sub_categories ? "LEFT JOIN categories parent_categories ON parent_categories.id = c.parent_category_id" : ""}
|
|
WHERE (c.id IN (#{tracked_category_ids_sql}))
|
|
OR c.parent_category_id IN (#{tracked_category_ids_sql})
|
|
#{has_sub_sub_categories ? "OR (parent_categories.id IS NOT NULL AND parent_categories.parent_category_id IN (#{tracked_category_ids_sql}))" : ""}
|
|
)
|
|
SQL
|
|
|
|
sql << <<~SQL if SiteSetting.tagging_enabled
|
|
OR topics.id IN (
|
|
SELECT tt.topic_id FROM topic_tags tt WHERE tt.tag_id IN (
|
|
SELECT tu.tag_id
|
|
FROM tag_users tu
|
|
WHERE tu.user_id = :user_id AND tu.notification_level >= :tracking
|
|
)
|
|
)
|
|
SQL
|
|
|
|
list.where(sql, user_id: user_id, tracking: NotificationLevels.all[:tracking])
|
|
end
|
|
|
|
def prioritize_pinned_topics(topics, options)
|
|
pinned_clause =
|
|
if options[:category_id]
|
|
+"topics.category_id = #{options[:category_id].to_i} AND"
|
|
else
|
|
+"pinned_globally AND "
|
|
end
|
|
|
|
pinned_clause << " pinned_at IS NOT NULL "
|
|
|
|
if @user
|
|
pinned_clause << " AND (topics.pinned_at > tu.cleared_pinned_at OR tu.cleared_pinned_at IS NULL)"
|
|
end
|
|
|
|
unpinned_topics = topics.where("NOT ( #{pinned_clause} )")
|
|
pinned_topics = topics.dup.offset(nil).where(pinned_clause).reorder(pinned_at: :desc)
|
|
|
|
per_page = options[:per_page] || per_page_setting
|
|
limit = per_page unless options[:limit] == false
|
|
page = options[:page].to_i
|
|
|
|
if page == 0
|
|
(pinned_topics + unpinned_topics)[0...limit] if limit
|
|
else
|
|
offset = (page * per_page) - pinned_topics.length
|
|
offset = 0 if offset <= 0
|
|
unpinned_topics.offset(offset).to_a
|
|
end
|
|
end
|
|
|
|
def create_list(filter, options = {}, topics = nil)
|
|
options[:filter] ||= filter
|
|
topics ||= default_results(options)
|
|
topics = yield(topics) if block_given?
|
|
|
|
options = options.merge(@options)
|
|
if %w[activity default].include?(options[:order] || "activity") && !options[:unordered] &&
|
|
filter != :private_messages
|
|
topics = prioritize_pinned_topics(topics, options)
|
|
end
|
|
|
|
topics = topics.to_a
|
|
|
|
if options[:preload_posters]
|
|
user_ids = []
|
|
topics.each do |ft|
|
|
user_ids << ft.user_id << ft.last_post_user_id << ft.featured_user_ids <<
|
|
ft.allowed_user_ids
|
|
end
|
|
|
|
user_lookup = UserLookup.new(user_ids)
|
|
|
|
# memoize for loop so we don't keep looking these up
|
|
translations = TopicPostersSummary.translations
|
|
|
|
topics.each do |t|
|
|
t.posters = t.posters_summary(user_lookup: user_lookup, translations: translations)
|
|
end
|
|
end
|
|
|
|
topics.each do |t|
|
|
t.allowed_user_ids = filter == :private_messages ? t.allowed_users.map { |u| u.id } : []
|
|
end
|
|
|
|
list = TopicList.new(filter, @user, topics, options.merge(@options))
|
|
list.per_page = options[:per_page] || per_page_setting
|
|
list
|
|
end
|
|
|
|
def latest_results(options = {})
|
|
result = default_results(options)
|
|
result = remove_muted(result, @user, options)
|
|
result = apply_shared_drafts(result, get_category_id(options[:category]), options)
|
|
|
|
# plugins can remove topics here:
|
|
self.class.results_filter_callbacks.each do |filter_callback|
|
|
result = filter_callback.call(:latest, result, @user, options)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def unseen_results(options = {})
|
|
result = default_results(options)
|
|
result = unseen_filter(result, @user.first_seen_at, @user.whisperer?) if @user
|
|
result = remove_muted(result, @user, options)
|
|
result = apply_shared_drafts(result, get_category_id(options[:category]), options)
|
|
|
|
# plugins can remove topics here:
|
|
self.class.results_filter_callbacks.each do |filter_callback|
|
|
result = filter_callback.call(:latest, result, @user, options)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def unread_results(options = {})
|
|
result =
|
|
TopicQuery.unread_filter(
|
|
default_results(options.reverse_merge(unordered: true)),
|
|
whisperer: @user&.whisperer?,
|
|
).order("CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END")
|
|
|
|
result = apply_max_age_limit(result, options)
|
|
|
|
self.class.results_filter_callbacks.each do |filter_callback|
|
|
result = filter_callback.call(:unread, result, @user, options)
|
|
end
|
|
|
|
suggested_ordering(result, options)
|
|
end
|
|
|
|
def new_results(options = {})
|
|
# TODO does this make sense or should it be ordered on created_at
|
|
# it is ordering on bumped_at now
|
|
result =
|
|
TopicQuery.new_filter(
|
|
default_results(options.reverse_merge(unordered: true)),
|
|
treat_as_new_topic_start_date: @user.user_option.treat_as_new_topic_start_date,
|
|
)
|
|
result = remove_muted(result, @user, options)
|
|
result = remove_dismissed(result, @user)
|
|
|
|
self.class.results_filter_callbacks.each do |filter_callback|
|
|
result = filter_callback.call(:new, result, @user, options)
|
|
end
|
|
|
|
suggested_ordering(result, options)
|
|
end
|
|
|
|
def new_and_unread_results(options = {})
|
|
base = default_results(options.reverse_merge(unordered: true))
|
|
|
|
new_results =
|
|
TopicQuery.new_filter(
|
|
base,
|
|
treat_as_new_topic_start_date: @user.user_option.treat_as_new_topic_start_date,
|
|
)
|
|
new_results = remove_muted(new_results, @user, options)
|
|
new_results = remove_dismissed(new_results, @user)
|
|
|
|
unread_results =
|
|
apply_max_age_limit(TopicQuery.unread_filter(base, whisperer: @user&.whisperer?), options)
|
|
|
|
base.joins_values.concat(new_results.joins_values, unread_results.joins_values)
|
|
base.joins_values.uniq!
|
|
results = base.merge(new_results.or(unread_results))
|
|
|
|
results = results.order("CASE WHEN topics.user_id = tu.user_id THEN 1 ELSE 2 END")
|
|
suggested_ordering(results, options)
|
|
end
|
|
|
|
protected
|
|
|
|
def per_page_setting
|
|
DEFAULT_PER_PAGE_COUNT
|
|
end
|
|
|
|
def apply_shared_drafts(result, category_id, options)
|
|
# PERF: avoid any penalty if there are no shared drafts enabled
|
|
# on some sites the cost can be high eg: gearbox
|
|
return result if SiteSetting.shared_drafts_category == ""
|
|
|
|
drafts_category_id = SiteSetting.shared_drafts_category.to_i
|
|
viewing_shared = category_id && category_id == drafts_category_id
|
|
|
|
if guardian.can_see_shared_draft?
|
|
if options[:destination_category_id]
|
|
destination_category_id = get_category_id(options[:destination_category_id])
|
|
topic_ids = SharedDraft.where(category_id: destination_category_id).pluck(:topic_id)
|
|
|
|
return result.where(id: topic_ids)
|
|
end
|
|
|
|
return result.includes(:shared_draft).references(:shared_draft) if viewing_shared
|
|
elsif viewing_shared
|
|
return(
|
|
result.joins("LEFT OUTER JOIN shared_drafts sd ON sd.topic_id = topics.id").where(
|
|
"sd.id IS NULL",
|
|
)
|
|
)
|
|
end
|
|
|
|
result.where("topics.category_id != ?", drafts_category_id)
|
|
end
|
|
|
|
def apply_ordering(result, options)
|
|
sort_column = SORTABLE_MAPPING[options[:order]] || "default"
|
|
sort_dir = (options[:ascending] == "true") ? "ASC" : "DESC"
|
|
|
|
# If we are sorting in the default order desc, we should consider including pinned
|
|
# topics. Otherwise, just use bumped_at.
|
|
if sort_column == "default"
|
|
if sort_dir == "DESC"
|
|
# If something requires a custom order, for example "unread" which sorts the least read
|
|
# to the top, do nothing
|
|
return result if options[:unordered]
|
|
end
|
|
sort_column = "bumped_at"
|
|
end
|
|
|
|
# If we are sorting by category, actually use the name
|
|
if sort_column == "category_id"
|
|
# TODO forces a table scan, slow
|
|
return result.references(:categories).order(<<~SQL)
|
|
CASE WHEN categories.id = #{SiteSetting.uncategorized_category_id.to_i} THEN '' ELSE categories.name END #{sort_dir}
|
|
SQL
|
|
end
|
|
|
|
if sort_column == "op_likes"
|
|
return(
|
|
result.includes(:first_post).order(
|
|
"(SELECT like_count FROM posts p3 WHERE p3.topic_id = topics.id AND p3.post_number = 1) #{sort_dir}",
|
|
)
|
|
)
|
|
end
|
|
|
|
if sort_column.start_with?("custom_fields")
|
|
field = sort_column.split(".")[1]
|
|
return(
|
|
result.order(
|
|
"(SELECT CASE WHEN EXISTS (SELECT true FROM topic_custom_fields tcf WHERE tcf.topic_id::integer = topics.id::integer AND tcf.name = '#{field}') THEN (SELECT value::integer FROM topic_custom_fields tcf WHERE tcf.topic_id::integer = topics.id::integer AND tcf.name = '#{field}') ELSE 0 END) #{sort_dir}",
|
|
)
|
|
)
|
|
end
|
|
|
|
result.order("topics.#{sort_column} #{sort_dir}")
|
|
end
|
|
|
|
def get_category_id(category_id_or_slug)
|
|
return nil unless category_id_or_slug.present?
|
|
category_id = category_id_or_slug.to_i
|
|
|
|
if category_id == 0
|
|
category_id = Category.where(slug: category_id_or_slug, parent_category_id: nil).pick(:id)
|
|
end
|
|
|
|
category_id
|
|
end
|
|
|
|
# Create results based on a bunch of default options
|
|
def default_results(options = {})
|
|
options.reverse_merge!(@options)
|
|
options.reverse_merge!(per_page: per_page_setting) unless options[:limit] == false
|
|
|
|
# Whether to include unlisted (visible = false) topics
|
|
viewing_own_topics = @user && @user.id == options[:filtered_to_user]
|
|
|
|
if options[:visible].nil?
|
|
options[:visible] = true if @user.nil? || @user.regular?
|
|
options[:visible] = false if @guardian.can_see_unlisted_topics? || viewing_own_topics
|
|
end
|
|
|
|
# Start with a list of all topics
|
|
result = Topic.includes(:category)
|
|
|
|
if @user
|
|
result =
|
|
result.joins(
|
|
"LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{@user.id.to_i})",
|
|
).references("tu")
|
|
end
|
|
|
|
category_id = get_category_id(options[:category])
|
|
@options[:category_id] = category_id
|
|
if category_id
|
|
if options[:no_subcategories]
|
|
result = result.where("topics.category_id = ?", category_id)
|
|
else
|
|
result = result.where("topics.category_id IN (?)", Category.subcategory_ids(category_id))
|
|
if !SiteSetting.show_category_definitions_in_topic_lists
|
|
result =
|
|
result.where(
|
|
"categories.topic_id IS DISTINCT FROM topics.id OR topics.category_id = ?",
|
|
category_id,
|
|
)
|
|
end
|
|
end
|
|
result = result.references(:categories)
|
|
|
|
if !@options[:order]
|
|
filter = (options[:filter] || options[:f])
|
|
# category default sort order
|
|
sort_order, sort_ascending =
|
|
Category.where(id: category_id).pick(:sort_order, :sort_ascending)
|
|
if sort_order && (filter.blank? || %i[latest unseen].include?(filter))
|
|
options[:order] = sort_order
|
|
options[:ascending] = !!sort_ascending ? "true" : "false"
|
|
else
|
|
options[:order] = "default"
|
|
options[:ascending] = "false"
|
|
end
|
|
end
|
|
end
|
|
|
|
if SiteSetting.tagging_enabled
|
|
result = result.preload(:tags)
|
|
|
|
tags_arg = @options[:tags]
|
|
|
|
if tags_arg && tags_arg.size > 0
|
|
tags_arg = tags_arg.split if String === tags_arg
|
|
|
|
tags_arg =
|
|
tags_arg.map do |t|
|
|
if String === t
|
|
t.downcase
|
|
else
|
|
t
|
|
end
|
|
end
|
|
|
|
tags_query = tags_arg[0].is_a?(String) ? Tag.where_name(tags_arg) : Tag.where(id: tags_arg)
|
|
tags = tags_query.select(:id, :target_tag_id).map { |t| t.target_tag_id || t.id }.uniq
|
|
|
|
if ActiveModel::Type::Boolean.new.cast(@options[:match_all_tags])
|
|
# ALL of the given tags:
|
|
if tags_arg.length == tags.length
|
|
tags.each_with_index do |tag, index|
|
|
sql_alias = ["t", index].join
|
|
result =
|
|
result.joins(
|
|
"INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag}",
|
|
)
|
|
end
|
|
else
|
|
result = result.none # don't return any results unless all tags exist in the database
|
|
end
|
|
else
|
|
# ANY of the given tags:
|
|
result = result.joins(:tags).where("tags.id in (?)", tags)
|
|
end
|
|
|
|
# TODO: this is very side-effecty and should be changed
|
|
# It is done cause further up we expect normalized tags
|
|
@options[:tags] = tags
|
|
elsif @options[:no_tags]
|
|
# the following will do: ("topics"."id" NOT IN (SELECT DISTINCT "topic_tags"."topic_id" FROM "topic_tags"))
|
|
result = result.where.not(id: TopicTag.distinct.pluck(:topic_id))
|
|
end
|
|
|
|
if @options[:exclude_tag].present? &&
|
|
!DiscourseTagging.hidden_tag_names(@guardian).include?(@options[:exclude_tag])
|
|
result = result.where(<<~SQL, name: @options[:exclude_tag])
|
|
topics.id NOT IN (
|
|
SELECT topic_tags.topic_id
|
|
FROM topic_tags
|
|
INNER JOIN tags ON tags.id = topic_tags.tag_id
|
|
WHERE tags.name = :name
|
|
)
|
|
SQL
|
|
end
|
|
end
|
|
|
|
result = apply_ordering(result, options)
|
|
|
|
all_listable_topics =
|
|
@guardian.filter_allowed_categories(
|
|
Topic.unscoped.listable_topics,
|
|
category_id_column: "categories.id",
|
|
)
|
|
|
|
if options[:include_pms] || options[:include_all_pms]
|
|
all_pm_topics =
|
|
if options[:include_all_pms] && @guardian.is_admin?
|
|
Topic.unscoped.private_messages
|
|
else
|
|
Topic.unscoped.private_messages_for_user(@user)
|
|
end
|
|
result = result.merge(all_listable_topics.or(all_pm_topics))
|
|
else
|
|
result = result.merge(all_listable_topics)
|
|
end
|
|
|
|
# Don't include the category topics if excluded
|
|
if options[:no_definitions]
|
|
result = result.where("COALESCE(categories.topic_id, 0) <> topics.id")
|
|
end
|
|
|
|
result = result.limit(options[:per_page]) unless options[:limit] == false
|
|
result = result.visible if options[:visible]
|
|
result =
|
|
result.where.not(topics: { id: options[:except_topic_ids] }).references(:topics) if options[
|
|
:except_topic_ids
|
|
]
|
|
|
|
if options[:page]
|
|
offset = options[:page].to_i * options[:per_page]
|
|
result = result.offset(offset) if offset > 0
|
|
end
|
|
|
|
if options[:topic_ids]
|
|
result = result.where("topics.id in (?)", options[:topic_ids]).references(:topics)
|
|
end
|
|
|
|
if search = options[:search].presence
|
|
result =
|
|
result.where(
|
|
"topics.id in (select pp.topic_id from post_search_data pd join posts pp on pp.id = pd.post_id where pd.search_data @@ #{Search.ts_query(term: search.to_s)})",
|
|
)
|
|
end
|
|
|
|
# NOTE protect against SYM attack can be removed with Ruby 2.2
|
|
#
|
|
state = options[:state]
|
|
if @user && state && TopicUser.notification_levels.keys.map(&:to_s).include?(state)
|
|
level = TopicUser.notification_levels[state.to_sym]
|
|
result =
|
|
result.where(
|
|
"topics.id IN (
|
|
SELECT topic_id
|
|
FROM topic_users
|
|
WHERE user_id = ? AND
|
|
notification_level = ?)",
|
|
@user.id,
|
|
level,
|
|
)
|
|
end
|
|
|
|
if before = options[:before]
|
|
if (before = before.to_i) > 0
|
|
result = result.where("topics.created_at < ?", before.to_i.days.ago)
|
|
end
|
|
end
|
|
|
|
if bumped_before = options[:bumped_before]
|
|
if (bumped_before = bumped_before.to_i) > 0
|
|
result = result.where("topics.bumped_at < ?", bumped_before.to_i.days.ago)
|
|
end
|
|
end
|
|
|
|
if status = options[:status]
|
|
result =
|
|
TopicsFilter.new(
|
|
scope: result,
|
|
guardian: @guardian,
|
|
category_id: options[:category],
|
|
).filter(status: options[:status])
|
|
end
|
|
|
|
if (filter = (options[:filter] || options[:f])) && @user
|
|
action = (PostActionType.types[:like] if filter == "liked")
|
|
if action
|
|
result =
|
|
result.where(
|
|
"topics.id IN (SELECT pp.topic_id
|
|
FROM post_actions pa
|
|
JOIN posts pp ON pp.id = pa.post_id
|
|
WHERE pa.user_id = :user_id AND
|
|
pa.post_action_type_id = :action AND
|
|
pa.deleted_at IS NULL
|
|
)",
|
|
user_id: @user.id,
|
|
action: action,
|
|
)
|
|
end
|
|
|
|
result = TopicQuery.tracked_filter(result, @user.id) if filter == "tracked"
|
|
end
|
|
|
|
result = result.where("topics.posts_count <= ?", options[:max_posts]) if options[
|
|
:max_posts
|
|
].present?
|
|
result = result.where("topics.posts_count >= ?", options[:min_posts]) if options[
|
|
:min_posts
|
|
].present?
|
|
|
|
result = TopicQuery.apply_custom_filters(result, self)
|
|
|
|
result
|
|
end
|
|
|
|
def remove_muted(list, user, options)
|
|
list = remove_muted_topics(list, user) unless options && options[:state] == "muted"
|
|
list = remove_muted_categories(list, user, exclude: options[:category])
|
|
TopicQuery.remove_muted_tags(list, user, options)
|
|
end
|
|
|
|
def remove_muted_topics(list, user)
|
|
if user
|
|
list =
|
|
list.where(
|
|
"COALESCE(tu.notification_level,1) > :muted",
|
|
muted: TopicUser.notification_levels[:muted],
|
|
)
|
|
end
|
|
|
|
list
|
|
end
|
|
|
|
def remove_muted_categories(list, user, opts = nil)
|
|
category_id = get_category_id(opts[:exclude]) if opts
|
|
|
|
if user
|
|
list =
|
|
list
|
|
.references("cu")
|
|
.joins(
|
|
"LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id}",
|
|
)
|
|
.where(
|
|
"topics.category_id = :category_id
|
|
OR
|
|
(COALESCE(category_users.notification_level, :default) <> :muted AND (topics.category_id IS NULL OR topics.category_id NOT IN(:indirectly_muted_category_ids)))
|
|
OR tu.notification_level > :regular",
|
|
category_id: category_id || -1,
|
|
default: CategoryUser.default_notification_level,
|
|
indirectly_muted_category_ids:
|
|
CategoryUser.indirectly_muted_category_ids(user).presence || [-1],
|
|
muted: CategoryUser.notification_levels[:muted],
|
|
regular: TopicUser.notification_levels[:regular],
|
|
)
|
|
elsif SiteSetting.mute_all_categories_by_default
|
|
category_ids = [
|
|
SiteSetting.default_categories_watching.split("|"),
|
|
SiteSetting.default_categories_tracking.split("|"),
|
|
SiteSetting.default_categories_watching_first_post.split("|"),
|
|
SiteSetting.default_categories_normal.split("|"),
|
|
].flatten.map(&:to_i)
|
|
category_ids << category_id if category_id.present? && category_ids.exclude?(category_id)
|
|
|
|
list = list.where("categories.id IN (?)", category_ids) if category_ids.present?
|
|
else
|
|
category_ids = SiteSetting.default_categories_muted.split("|").map(&:to_i)
|
|
category_ids -= [category_id] if category_id.present? && category_ids.include?(category_id)
|
|
|
|
list = list.where("categories.id NOT IN (?)", category_ids) if category_ids.present?
|
|
end
|
|
|
|
list
|
|
end
|
|
|
|
def self.remove_muted_tags(list, user, opts = {})
|
|
if !SiteSetting.tagging_enabled || SiteSetting.remove_muted_tags_from_latest == "never"
|
|
return list
|
|
end
|
|
|
|
muted_tag_ids = []
|
|
|
|
if user.present?
|
|
muted_tag_ids = TagUser.lookup(user, :muted).pluck(:tag_id)
|
|
else
|
|
muted_tag_names = SiteSetting.default_tags_muted.split("|")
|
|
|
|
muted_tag_ids = Tag.where(name: muted_tag_names).pluck(:id) if muted_tag_names.present?
|
|
end
|
|
|
|
return list if muted_tag_ids.blank?
|
|
|
|
# if viewing the topic list for a muted tag, show all the topics
|
|
if !opts[:no_tags] && opts[:tags].present?
|
|
if TagUser
|
|
.lookup(user, :muted)
|
|
.joins(:tag)
|
|
.where("lower(tags.name) = ?", opts[:tags].first.downcase)
|
|
.exists?
|
|
return list
|
|
end
|
|
end
|
|
|
|
if SiteSetting.remove_muted_tags_from_latest == "always"
|
|
list =
|
|
list.where(
|
|
"
|
|
NOT EXISTS(
|
|
SELECT 1
|
|
FROM topic_tags tt
|
|
WHERE tt.tag_id IN (:tag_ids)
|
|
AND tt.topic_id = topics.id)",
|
|
tag_ids: muted_tag_ids,
|
|
)
|
|
else
|
|
list =
|
|
list.where(
|
|
"
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM topic_tags tt
|
|
WHERE tt.tag_id NOT IN (:tag_ids)
|
|
AND tt.topic_id = topics.id
|
|
) OR NOT EXISTS (SELECT 1 FROM topic_tags tt WHERE tt.topic_id = topics.id)",
|
|
tag_ids: muted_tag_ids,
|
|
)
|
|
end
|
|
end
|
|
|
|
def remove_dismissed(list, user)
|
|
if user
|
|
list.joins(<<~SQL).where("dismissed_topic_users.id IS NULL")
|
|
LEFT JOIN dismissed_topic_users
|
|
ON dismissed_topic_users.topic_id = topics.id
|
|
AND dismissed_topic_users.user_id = #{user.id.to_i}
|
|
SQL
|
|
else
|
|
list
|
|
end
|
|
end
|
|
|
|
def new_messages(params)
|
|
TopicQuery.new_filter(
|
|
messages_for_groups_or_user(params[:my_group_ids]),
|
|
treat_as_new_topic_start_date: Time.at(SiteSetting.min_new_topics_time).to_datetime,
|
|
).limit(params[:count])
|
|
end
|
|
|
|
def unread_messages(params)
|
|
query =
|
|
TopicQuery.unread_filter(
|
|
messages_for_groups_or_user(params[:my_group_ids]),
|
|
whisperer: @user.whisperer?,
|
|
)
|
|
|
|
first_unread_pm_at =
|
|
if params[:my_group_ids].present?
|
|
GroupUser.where(user_id: @user.id, group_id: params[:my_group_ids]).minimum(
|
|
:first_unread_pm_at,
|
|
)
|
|
else
|
|
UserStat.where(user_id: @user.id).pick(:first_unread_pm_at)
|
|
end
|
|
|
|
query = query.where("topics.updated_at >= ?", first_unread_pm_at) if first_unread_pm_at
|
|
query = query.limit(params[:count]) if params[:count]
|
|
query
|
|
end
|
|
|
|
def related_messages_user(params)
|
|
messages = messages_for_user.limit(params[:count])
|
|
messages = allowed_messages(messages, params)
|
|
end
|
|
|
|
def related_messages_group(params)
|
|
messages = messages_for_groups_or_user(params[:my_group_ids]).limit(params[:count])
|
|
messages = allowed_messages(messages, params)
|
|
end
|
|
|
|
def allowed_messages(messages, params)
|
|
user_ids = (params[:target_user_ids] || [])
|
|
group_ids = ((params[:target_group_ids] - params[:my_group_ids]) || [])
|
|
|
|
if user_ids.present?
|
|
messages =
|
|
messages.joins(
|
|
"
|
|
LEFT JOIN topic_allowed_users ta2
|
|
ON topics.id = ta2.topic_id
|
|
AND #{DB.sql_fragment("ta2.user_id IN (?)", user_ids)}
|
|
",
|
|
)
|
|
end
|
|
|
|
if group_ids.present?
|
|
messages =
|
|
messages.joins(
|
|
"
|
|
LEFT JOIN topic_allowed_groups tg2
|
|
ON topics.id = tg2.topic_id
|
|
AND #{DB.sql_fragment("tg2.group_id IN (?)", group_ids)}
|
|
",
|
|
)
|
|
end
|
|
|
|
messages =
|
|
if user_ids.present? && group_ids.present?
|
|
messages.where("ta2.topic_id IS NOT NULL OR tg2.topic_id IS NOT NULL")
|
|
elsif user_ids.present?
|
|
messages.where("ta2.topic_id IS NOT NULL")
|
|
elsif group_ids.present?
|
|
messages.where("tg2.topic_id IS NOT NULL")
|
|
end
|
|
end
|
|
|
|
def messages_for_groups_or_user(group_ids)
|
|
if group_ids.present?
|
|
base_messages.joins(
|
|
"
|
|
LEFT JOIN (
|
|
SELECT * FROM topic_allowed_groups _tg
|
|
LEFT JOIN group_users gu
|
|
ON gu.user_id = #{@user.id.to_i}
|
|
AND gu.group_id = _tg.group_id
|
|
WHERE #{DB.sql_fragment("gu.group_id IN (?)", group_ids)}
|
|
) tg ON topics.id = tg.topic_id
|
|
",
|
|
).where("tg.topic_id IS NOT NULL")
|
|
else
|
|
messages_for_user
|
|
end
|
|
end
|
|
|
|
def messages_for_user
|
|
base_messages.joins(
|
|
"
|
|
LEFT JOIN topic_allowed_users ta
|
|
ON topics.id = ta.topic_id
|
|
AND ta.user_id = #{@user.id.to_i}
|
|
",
|
|
).where("ta.topic_id IS NOT NULL")
|
|
end
|
|
|
|
def base_messages
|
|
query =
|
|
Topic.where("topics.archetype = ?", Archetype.private_message).joins(
|
|
"LEFT JOIN topic_users tu ON topics.id = tu.topic_id AND tu.user_id = #{@user.id.to_i}",
|
|
)
|
|
|
|
query = query.includes(:tags) if SiteSetting.tagging_enabled
|
|
query.order("topics.bumped_at DESC")
|
|
end
|
|
|
|
def random_suggested(topic, count, excluded_topic_ids = [])
|
|
result = default_results(unordered: true, per_page: count).where(closed: false, archived: false)
|
|
|
|
if SiteSetting.limit_suggested_to_category
|
|
excluded_topic_ids += Category.where(id: topic.category_id).pluck(:id)
|
|
else
|
|
excluded_topic_ids += Category.topic_ids.to_a
|
|
end
|
|
result =
|
|
result.where("topics.id NOT IN (?)", excluded_topic_ids) unless excluded_topic_ids.empty?
|
|
|
|
result = remove_muted_categories(result, @user)
|
|
result = remove_muted_topics(result, @user)
|
|
|
|
# If we are in a category, prefer it for the random results
|
|
if topic.category_id
|
|
result =
|
|
result.order("CASE WHEN topics.category_id = #{topic.category_id.to_i} THEN 0 ELSE 1 END")
|
|
end
|
|
|
|
# Best effort, it over selects, however if you have a high number
|
|
# of muted categories there is tiny chance we will not select enough
|
|
# in particular this can happen if current category is empty and tons
|
|
# of muted, big edge case
|
|
#
|
|
# we over select in case cache is stale
|
|
max = (count * 1.3).to_i
|
|
ids = SiteSetting.limit_suggested_to_category ? [] : RandomTopicSelector.next(max)
|
|
ids.concat(RandomTopicSelector.next(max, topic.category))
|
|
|
|
result.where(id: ids.uniq)
|
|
end
|
|
|
|
def suggested_ordering(result, options)
|
|
# Prefer unread in the same category
|
|
if options[:topic] && options[:topic].category_id
|
|
result =
|
|
result.order(
|
|
"CASE WHEN topics.category_id = #{options[:topic].category_id.to_i} THEN 0 ELSE 1 END",
|
|
)
|
|
end
|
|
|
|
result.order("topics.bumped_at DESC")
|
|
end
|
|
|
|
private
|
|
|
|
def unseen_filter(list, user_first_seen_at, whisperer)
|
|
list = list.where("topics.bumped_at >= ?", user_first_seen_at)
|
|
|
|
col_name = whisperer ? "highest_staff_post_number" : "highest_post_number"
|
|
list.where("tu.last_read_post_number IS NULL OR tu.last_read_post_number < topics.#{col_name}")
|
|
end
|
|
|
|
def apply_max_age_limit(results, options)
|
|
if @user
|
|
# micro optimisation so we don't load up all of user stats which we do not need
|
|
unread_at =
|
|
DB.query_single("select first_unread_at from user_stats where user_id = ?", @user.id).first
|
|
|
|
if max_age = options[:max_age]
|
|
max_age_date = max_age.days.ago
|
|
unread_at ||= max_age_date
|
|
unread_at = unread_at > max_age_date ? unread_at : max_age_date
|
|
end
|
|
|
|
# perf note, in the past we tried doing this in a subquery but performance was
|
|
# terrible, also tried with a join and it was bad
|
|
results = results.where("topics.updated_at >= ?", unread_at)
|
|
end
|
|
results
|
|
end
|
|
end
|