mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:23:17 -05:00
What is the problem?
Discourse allows admins to mark a tag as a synonym of another tag. For
example, "brunch" can be made a synonym of "lunch". When this happens,
all topics tagged with "brunch" are automatically retagged with "lunch",
and the `tags.target_tag_id` column on the synonym tag record is set to
point to the target tag.
However, several code paths did not account for synonyms:
1. **Search:** `Search#search_tags` and the hashtag advanced filter did
not resolve synonyms. When a user searched using a synonym name (e.g.
`tags:brunch`, `tags:brunch+eggs`, or `#brunch`), no results were
returned because:
- The `tags:` comma path queries `topic_tags` joined with `tags` by
name, but topics are tagged with the target tag "lunch", not the
synonym "brunch".
- The `tags:` plus path aggregates tag names per topic into a
tsvector and matches against the searched name, but the aggregated
names are target tag names, so "brunch" never matches.
- The `#` hashtag path picks the synonym tag's own `id` and queries
`topic_tags` by that ID, but topics store the target tag's ID.
2. **Filter route:** `TopicsFilter#tag_ids_from_tag_names` concatenated
both the synonym's own ID and the target tag ID. For match-all
queries (e.g. `tag:brunch`), this required a topic to have both IDs
in `topic_tags`, which never happens since only the target tag ID is
stored. For negation queries (e.g. `-tag:brunch`), the exclusion
targeted the synonym ID rather than the target, so no topics were
excluded.
What is the solution?
In `Search#search_tags`, add a synonym resolution step before the
existing comma/plus branching logic. It splits the match string into
individual tag names, queries for any that are synonyms via
`Tag.where_name(tag_names).where.not(target_tag_id: nil)`, builds a
name mapping, and replaces synonym names with their target tag names.
The replacement operates on the split array elements rather than using
substring replacement to avoid corrupting tag names that may contain
other tag names as substrings.
In the hashtag advanced filter, pick both `:id` and `:target_tag_id`
from the tag lookup and prefer `target_tag_id` when present, so the
query uses the target tag's ID instead of the synonym's own ID.
In `TopicsFilter#tag_ids_from_tag_names`, replace the transpose/concat
approach with `.map { |id, target_id| target_id || id }` to resolve
each tag to its canonical ID — the target for synonyms, or the tag's
own ID otherwise.
A partial index on `tags.target_tag_id` is added (scoped to
`WHERE target_tag_id IS NOT NULL`) to support efficient synonym lookups.
1087 lines
36 KiB
Ruby
1087 lines
36 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class TopicsFilter
|
|
attr_reader :topic_notification_levels
|
|
|
|
def initialize(guardian:, scope: Topic.all, loaded_topic_users_reference: false)
|
|
@loaded_topic_users_reference = loaded_topic_users_reference
|
|
@guardian = guardian || Guardian.new
|
|
@scope = scope
|
|
@topic_notification_levels = Set.new
|
|
end
|
|
|
|
FILTER_ALIASES = {
|
|
"categories" => "category",
|
|
"tags" => "tag",
|
|
"groups" => "group",
|
|
"user" => "users",
|
|
}
|
|
private_constant :FILTER_ALIASES
|
|
|
|
# Shared pattern for matching quoted values (single or double quotes)
|
|
QUOTED_VALUE_PATTERN = '"[^"]*"|\'[^\']*\''
|
|
private_constant :QUOTED_VALUE_PATTERN
|
|
|
|
# Pattern for extracting filter components: prefix, key, and value (supports quoted values)
|
|
FILTER_EXTRACTION_PATTERN =
|
|
/(?<key_prefix>(?:-|=|-=|=-))?(?<key>[\w-]+):(?<value>#{QUOTED_VALUE_PATTERN}|[^\s]+)/
|
|
private_constant :FILTER_EXTRACTION_PATTERN
|
|
|
|
# Pattern for tokenizing query string while preserving quoted values and filter:value pairs
|
|
# Note: wrap QUOTED_VALUE_PATTERN in non-capturing group to preserve alternation precedence
|
|
TOKENIZER_PATTERN =
|
|
/[\w-]+:(?:#{QUOTED_VALUE_PATTERN})|[\w-]+:[^\s]+|(?:#{QUOTED_VALUE_PATTERN})|[^\s]+/
|
|
private_constant :TOKENIZER_PATTERN
|
|
|
|
def filter_from_query_string(query_string)
|
|
return @scope if query_string.blank?
|
|
|
|
filters = {}
|
|
|
|
query_string.scan(FILTER_EXTRACTION_PATTERN) do |key_prefix, key, value|
|
|
key = FILTER_ALIASES[key] || key
|
|
|
|
filters[key] ||= {}
|
|
filters[key]["key_prefixes"] ||= []
|
|
filters[key]["key_prefixes"] << key_prefix
|
|
filters[key]["values"] ||= []
|
|
filters[key]["values"] << value
|
|
end
|
|
|
|
filters.each do |filter, hash|
|
|
key_prefixes = hash["key_prefixes"]
|
|
values = hash["values"]
|
|
|
|
filter_values = extract_and_validate_value_for(filter, values)
|
|
case filter
|
|
when "activity-before"
|
|
filter_by_activity(before: filter_values)
|
|
when "activity-after"
|
|
filter_by_activity(after: filter_values)
|
|
when "category"
|
|
filter_categories(values: key_prefixes.zip(filter_values))
|
|
when "created-after"
|
|
filter_by_created(after: filter_values)
|
|
when "created-before"
|
|
filter_by_created(before: filter_values)
|
|
when "created-by"
|
|
filter_created_by(names: filter_values.flat_map { |value| value.split(",") })
|
|
when "in"
|
|
filter_in(values: filter_values)
|
|
when "latest-post-after"
|
|
filter_by_latest_post(after: filter_values)
|
|
when "latest-post-before"
|
|
filter_by_latest_post(before: filter_values)
|
|
when "likes-min"
|
|
filter_by_number_of_likes(min: filter_values)
|
|
when "likes-max"
|
|
filter_by_number_of_likes(max: filter_values)
|
|
when "likes-op-min"
|
|
filter_by_number_of_likes_in_first_post(min: filter_values)
|
|
when "likes-op-max"
|
|
filter_by_number_of_likes_in_first_post(max: filter_values)
|
|
when "order"
|
|
order_by(values: filter_values)
|
|
when "users"
|
|
filter_users(values: key_prefixes.zip(filter_values))
|
|
when "group"
|
|
filter_groups(values: filter_values)
|
|
when "posts-min"
|
|
filter_by_number_of_posts(min: filter_values)
|
|
when "posts-max"
|
|
filter_by_number_of_posts(max: filter_values)
|
|
when "posters-min"
|
|
filter_by_number_of_posters(min: filter_values)
|
|
when "posters-max"
|
|
filter_by_number_of_posters(max: filter_values)
|
|
when "status"
|
|
filter_values.each { |status| @scope = filter_status(status: status) }
|
|
when "tag_group"
|
|
filter_tag_groups(values: key_prefixes.zip(filter_values))
|
|
when "tag"
|
|
filter_tags(values: key_prefixes.zip(filter_values))
|
|
when "locale"
|
|
filter_locale(values: key_prefixes.zip(filter_values))
|
|
when "views-min"
|
|
filter_by_number_of_views(min: filter_values)
|
|
when "views-max"
|
|
filter_by_number_of_views(max: filter_values)
|
|
else
|
|
if custom_filter = DiscoursePluginRegistry.custom_filter_mappings.find { _1.key?(filter) }
|
|
@scope = custom_filter[filter].call(@scope, filter_values, @guardian) || @scope
|
|
end
|
|
end
|
|
end
|
|
|
|
# Tokenize while preserving quoted values, then extract keywords (non-filter terms)
|
|
keywords =
|
|
query_string
|
|
.scan(TOKENIZER_PATTERN)
|
|
.reject { |word| word.include?(":") }
|
|
.map(&:strip)
|
|
.reject(&:empty?)
|
|
|
|
if keywords.present? && keywords.join(" ").length >= SiteSetting.min_search_term_length
|
|
ts_query = Search.ts_query(term: keywords.join(" "))
|
|
@scope = @scope.where(<<~SQL)
|
|
topics.id IN (
|
|
SELECT topic_id
|
|
FROM post_search_data
|
|
JOIN posts ON posts.id = post_search_data.post_id
|
|
WHERE search_data @@ #{ts_query} AND NOT posts.hidden AND posts.deleted_at IS NULL #{whisper_condition("posts")}
|
|
)
|
|
SQL
|
|
end
|
|
|
|
@scope
|
|
end
|
|
|
|
def self.add_filter_by_status(status, enabled: -> { true }, &block)
|
|
custom_status_filters[status] = { block:, enabled: }
|
|
end
|
|
|
|
def self.custom_status_filters
|
|
@custom_status_filters ||= {}
|
|
end
|
|
|
|
def filter_status(status:, category_id: nil)
|
|
case status
|
|
when "open"
|
|
@scope = @scope.where("NOT topics.closed AND NOT topics.archived")
|
|
when "closed"
|
|
@scope = @scope.where("topics.closed")
|
|
when "archived"
|
|
@scope = @scope.where("topics.archived")
|
|
when "listed"
|
|
@scope = @scope.where("topics.visible")
|
|
when "unlisted"
|
|
@scope = @scope.where("NOT topics.visible")
|
|
when "deleted"
|
|
category = category_id.present? ? Category.find_by(id: category_id) : nil
|
|
|
|
if @guardian.can_see_deleted_topics?(category)
|
|
@scope = @scope.unscope(where: :deleted_at).where.not(topics: { deleted_at: nil })
|
|
end
|
|
when "public"
|
|
@scope = @scope.joins(:category).where("NOT categories.read_restricted")
|
|
else
|
|
if custom_filter = TopicsFilter.custom_status_filters[status]
|
|
@scope = custom_filter[:block].call(@scope) if custom_filter[:enabled].call
|
|
end
|
|
end
|
|
|
|
@scope
|
|
end
|
|
|
|
def self.option_info(guardian)
|
|
results = [
|
|
{
|
|
name: "category:",
|
|
alias: "categories:",
|
|
description: I18n.t("filter.description.category"),
|
|
priority: 1,
|
|
type: "category",
|
|
delimiters: [{ name: ",", description: I18n.t("filter.description.category_any") }],
|
|
prefixes: [
|
|
{ name: "-", description: I18n.t("filter.description.exclude_category") },
|
|
{ name: "=", description: I18n.t("filter.description.category_without_subcategories") },
|
|
{
|
|
name: "-=",
|
|
description: I18n.t("filter.description.exclude_category_without_subcategories"),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "activity-before:",
|
|
description: I18n.t("filter.description.activity_before"),
|
|
type: "date",
|
|
},
|
|
{
|
|
name: "activity-after:",
|
|
description: I18n.t("filter.description.activity_after"),
|
|
type: "date",
|
|
},
|
|
{
|
|
name: "created-before:",
|
|
description: I18n.t("filter.description.created_before"),
|
|
type: "date",
|
|
},
|
|
{
|
|
name: "created-after:",
|
|
description: I18n.t("filter.description.created_after"),
|
|
priority: 1,
|
|
type: "date",
|
|
},
|
|
{
|
|
name: "created-by:",
|
|
description: I18n.t("filter.description.created_by"),
|
|
type: "username_group_list",
|
|
delimiters: [{ name: ",", description: I18n.t("filter.description.created_by_multiple") }],
|
|
},
|
|
{
|
|
name: "users:",
|
|
description: I18n.t("filter.description.users"),
|
|
type: "username",
|
|
priority: 1,
|
|
prefixes: [{ name: "-", description: I18n.t("filter.description.exclude_users") }],
|
|
delimiters: [
|
|
{ name: ",", description: I18n.t("filter.description.users_any") },
|
|
{ name: "+", description: I18n.t("filter.description.users_all") },
|
|
],
|
|
},
|
|
{
|
|
name: "latest-post-before:",
|
|
description: I18n.t("filter.description.latest_post_before"),
|
|
type: "date",
|
|
},
|
|
{
|
|
name: "latest-post-after:",
|
|
description: I18n.t("filter.description.latest_post_after"),
|
|
type: "date",
|
|
},
|
|
{ name: "likes-min:", description: I18n.t("filter.description.likes_min"), type: "number" },
|
|
{ name: "likes-max:", description: I18n.t("filter.description.likes_max"), type: "number" },
|
|
{
|
|
name: "likes-op-min:",
|
|
description: I18n.t("filter.description.likes_op_min"),
|
|
type: "number",
|
|
},
|
|
{
|
|
name: "likes-op-max:",
|
|
description: I18n.t("filter.description.likes_op_max"),
|
|
type: "number",
|
|
},
|
|
{ name: "posts-min:", description: I18n.t("filter.description.posts_min"), type: "number" },
|
|
{ name: "posts-max:", description: I18n.t("filter.description.posts_max"), type: "number" },
|
|
{
|
|
name: "posters-min:",
|
|
description: I18n.t("filter.description.posters_min"),
|
|
type: "number",
|
|
},
|
|
{
|
|
name: "posters-max:",
|
|
description: I18n.t("filter.description.posters_max"),
|
|
type: "number",
|
|
},
|
|
{ name: "views-min:", description: I18n.t("filter.description.views_min"), type: "number" },
|
|
{ name: "views-max:", description: I18n.t("filter.description.views_max"), type: "number" },
|
|
{ name: "status:", description: I18n.t("filter.description.status"), priority: 1 },
|
|
{ name: "status:open", description: I18n.t("filter.description.status_open") },
|
|
{ name: "status:closed", description: I18n.t("filter.description.status_closed") },
|
|
{ name: "status:archived", description: I18n.t("filter.description.status_archived") },
|
|
{ name: "status:listed", description: I18n.t("filter.description.status_listed") },
|
|
{ name: "status:unlisted", description: I18n.t("filter.description.status_unlisted") },
|
|
{ name: "status:deleted", description: I18n.t("filter.description.status_deleted") },
|
|
{ name: "status:public", description: I18n.t("filter.description.status_public") },
|
|
{ name: "order:", description: I18n.t("filter.description.order"), priority: 1 },
|
|
{ name: "order:activity", description: I18n.t("filter.description.order_activity") },
|
|
{ name: "order:activity-asc", description: I18n.t("filter.description.order_activity_asc") },
|
|
{ name: "order:category", description: I18n.t("filter.description.order_category") },
|
|
{ name: "order:category-asc", description: I18n.t("filter.description.order_category_asc") },
|
|
{ name: "order:created", description: I18n.t("filter.description.order_created") },
|
|
{ name: "order:created-asc", description: I18n.t("filter.description.order_created_asc") },
|
|
{ name: "order:latest-post", description: I18n.t("filter.description.order_latest_post") },
|
|
{
|
|
name: "order:latest-post-asc",
|
|
description: I18n.t("filter.description.order_latest_post_asc"),
|
|
},
|
|
{ name: "order:likes", description: I18n.t("filter.description.order_likes") },
|
|
{ name: "order:likes-asc", description: I18n.t("filter.description.order_likes_asc") },
|
|
{ name: "order:likes-op", description: I18n.t("filter.description.order_likes_op") },
|
|
{ name: "order:likes-op-asc", description: I18n.t("filter.description.order_likes_op_asc") },
|
|
{ name: "order:posters", description: I18n.t("filter.description.order_posters") },
|
|
{ name: "order:posters-asc", description: I18n.t("filter.description.order_posters_asc") },
|
|
{ name: "order:title", description: I18n.t("filter.description.order_title") },
|
|
{ name: "order:title-asc", description: I18n.t("filter.description.order_title_asc") },
|
|
{ name: "order:views", description: I18n.t("filter.description.order_views") },
|
|
{ name: "order:views-asc", description: I18n.t("filter.description.order_views_asc") },
|
|
{ name: "order:hot", description: I18n.t("filter.description.order_hot") },
|
|
{ name: "order:hot-asc", description: I18n.t("filter.description.order_hot_asc") },
|
|
{ name: "order:read", description: I18n.t("filter.description.order_read") },
|
|
{ name: "order:read-asc", description: I18n.t("filter.description.order_read_asc") },
|
|
]
|
|
|
|
if guardian.authenticated?
|
|
results.concat(
|
|
[
|
|
{ name: "in:", description: I18n.t("filter.description.in"), priority: 1 },
|
|
{ name: "in:pinned", description: I18n.t("filter.description.in_pinned") },
|
|
{ name: "in:bookmarked", description: I18n.t("filter.description.in_bookmarked") },
|
|
{ name: "in:watching", description: I18n.t("filter.description.in_watching") },
|
|
{ name: "in:tracking", description: I18n.t("filter.description.in_tracking") },
|
|
{ name: "in:muted", description: I18n.t("filter.description.in_muted") },
|
|
{ name: "in:normal", description: I18n.t("filter.description.in_normal") },
|
|
{
|
|
name: "in:watching_first_post",
|
|
description: I18n.t("filter.description.in_watching_first_post"),
|
|
},
|
|
{ name: "in:new", description: I18n.t("filter.description.in_new") },
|
|
{ name: "in:new-replies", description: I18n.t("filter.description.in_new_replies") },
|
|
{ name: "in:new-topics", description: I18n.t("filter.description.in_new_topics") },
|
|
{ name: "in:unseen", description: I18n.t("filter.description.in_unseen") },
|
|
],
|
|
)
|
|
end
|
|
|
|
if SiteSetting.tagging_enabled?
|
|
results.push(
|
|
{
|
|
name: "tag:",
|
|
description: I18n.t("filter.description.tag"),
|
|
alias: "tags:",
|
|
priority: 1,
|
|
type: "tag",
|
|
delimiters: [
|
|
{ name: ",", description: I18n.t("filter.description.tags_any") },
|
|
{ name: "+", description: I18n.t("filter.description.tags_all") },
|
|
],
|
|
prefixes: [{ name: "-", description: I18n.t("filter.description.exclude_tag") }],
|
|
},
|
|
)
|
|
results.push(
|
|
{
|
|
name: "tag_group:",
|
|
description: I18n.t("filter.description.tag_group"),
|
|
type: "tag_group",
|
|
prefixes: [{ name: "-", description: I18n.t("filter.description.exclude_tag_group") }],
|
|
},
|
|
)
|
|
end
|
|
|
|
# Group participation filter (any/all)
|
|
results.push(
|
|
{
|
|
name: "group:",
|
|
alias: "groups:",
|
|
description: I18n.t("filter.description.group"),
|
|
type: "group",
|
|
priority: 1,
|
|
delimiters: [
|
|
{ name: ",", description: I18n.t("filter.description.groups_any") },
|
|
{ name: "+", description: I18n.t("filter.description.groups_all") },
|
|
],
|
|
},
|
|
)
|
|
|
|
# Locale filter
|
|
results.push(
|
|
{
|
|
name: "locale:",
|
|
description: I18n.t("filter.description.locale"),
|
|
type: "text",
|
|
delimiters: [{ name: ",", description: I18n.t("filter.description.locale_any") }],
|
|
prefixes: [{ name: "-", description: I18n.t("filter.description.exclude_locale") }],
|
|
},
|
|
)
|
|
|
|
# this modifier allows custom plugins to add UI tips in the /filter route
|
|
DiscoursePluginRegistry.apply_modifier(:topics_filter_options, results, guardian)
|
|
end
|
|
|
|
private
|
|
|
|
YYYY_MM_DD_REGEXP =
|
|
/\A(?<year>[12][0-9]{3})-(?<month>0?[1-9]|1[0-2])-(?<day>0?[1-9]|[12]\d|3[01])\z/
|
|
private_constant :YYYY_MM_DD_REGEXP
|
|
|
|
def extract_and_validate_value_for(filter, values)
|
|
case filter
|
|
when "activity-before", "activity-after", "created-before", "created-after",
|
|
"latest-post-before", "latest-post-after"
|
|
value = values.last
|
|
|
|
if match_data = value.match(YYYY_MM_DD_REGEXP)
|
|
Time.zone.parse(
|
|
"#{match_data[:year].to_i}-#{match_data[:month].to_i}-#{match_data[:day].to_i}",
|
|
)
|
|
elsif value =~ /\A\d+\z/
|
|
# Handle integer as number of days ago (0 = today at midnight)
|
|
days = value.to_i
|
|
return nil if days < 0
|
|
days.days.ago.beginning_of_day
|
|
end
|
|
when "likes-min", "likes-max", "likes-op-min", "likes-op-max", "posts-min", "posts-max",
|
|
"posters-min", "posters-max", "views-min", "views-max"
|
|
value = values.last
|
|
value if value =~ /\A\d+\z/
|
|
when "order"
|
|
values.flat_map { |value| value.split(",") }
|
|
when "created-by"
|
|
values.flat_map { |value| value.split(",").map { |username| username.delete_prefix("@") } }
|
|
else
|
|
values
|
|
end
|
|
end
|
|
|
|
def filter_by_topic_range(column_name:, min: nil, max: nil, scope: nil)
|
|
{ min => ">=", max => "<=" }.each do |value, operator|
|
|
next if !value
|
|
@scope = (scope || @scope).where("#{column_name} #{operator} ?", value)
|
|
end
|
|
end
|
|
|
|
def filter_by_activity(before: nil, after: nil)
|
|
filter_by_topic_range(column_name: "topics.bumped_at", min: after, max: before)
|
|
end
|
|
|
|
def filter_by_created(before: nil, after: nil)
|
|
filter_by_topic_range(column_name: "topics.created_at", min: after, max: before)
|
|
end
|
|
|
|
def filter_by_latest_post(before: nil, after: nil)
|
|
filter_by_topic_range(column_name: "topics.last_posted_at", min: after, max: before)
|
|
end
|
|
|
|
def filter_by_number_of_posts(min: nil, max: nil)
|
|
filter_by_topic_range(column_name: "topics.posts_count", min:, max:)
|
|
end
|
|
|
|
def filter_by_number_of_posters(min: nil, max: nil)
|
|
filter_by_topic_range(column_name: "topics.participant_count", min:, max:)
|
|
end
|
|
|
|
def filter_by_number_of_likes(min: nil, max: nil)
|
|
filter_by_topic_range(column_name: "topics.like_count", min:, max:)
|
|
end
|
|
|
|
def filter_by_number_of_likes_in_first_post(min: nil, max: nil)
|
|
filter_by_topic_range(
|
|
column_name: "first_posts.like_count",
|
|
min:,
|
|
max:,
|
|
scope: self.joins_first_posts(@scope),
|
|
)
|
|
end
|
|
|
|
def filter_by_number_of_views(min: nil, max: nil)
|
|
filter_by_topic_range(column_name: "topics.views", min:, max:)
|
|
end
|
|
|
|
def calculate_all_or_any(value)
|
|
require_all = nil
|
|
names = nil
|
|
|
|
if value.include?("+")
|
|
names = value.split("+")
|
|
require_all = true
|
|
if value.include?(",")
|
|
# no mix and match
|
|
return nil, []
|
|
end
|
|
else
|
|
names = value.split(",")
|
|
require_all = false
|
|
if value.include?("+")
|
|
# no mix and match
|
|
return nil, []
|
|
end
|
|
end
|
|
|
|
[require_all, names.map(&:downcase).reject(&:blank?)]
|
|
end
|
|
|
|
# users:a,b => any of a or b participated in the topic
|
|
# users:a+b => both a and b participated in the topic
|
|
# -users:a,b => neither a nor b participated in the topic
|
|
# -users:a+b => at least one of a or b did not participate in the topic
|
|
def filter_users(values:)
|
|
values.each do |prefix, value|
|
|
require_all, usernames = calculate_all_or_any(value)
|
|
|
|
if usernames.empty?
|
|
@scope = @scope.none
|
|
next
|
|
end
|
|
|
|
user_ids = User.not_staged.where("username_lower IN (?)", usernames).pluck(:id)
|
|
|
|
if user_ids.empty?
|
|
@scope = @scope.none
|
|
next
|
|
end
|
|
|
|
if require_all
|
|
if user_ids.length < usernames.length
|
|
@scope = @scope.none
|
|
next
|
|
end
|
|
|
|
# A possible alternative is to select the topics with the users with the least posts
|
|
# then expand to all of the rest of the users, this can limit the scanning
|
|
if prefix == "-"
|
|
@scope = @scope.where(<<~SQL, user_ids: user_ids, user_count: user_ids.length)
|
|
topics.id NOT IN (
|
|
SELECT p1.topic_id
|
|
FROM posts p1
|
|
WHERE p1.user_id IN (:user_ids) AND p1.deleted_at IS NULL #{whisper_condition("p1")}
|
|
GROUP BY p1.topic_id
|
|
HAVING COUNT(DISTINCT p1.user_id) = :user_count
|
|
)
|
|
SQL
|
|
else
|
|
user_ids.each_with_index { |uid, idx| @scope = @scope.where(<<~SQL) }
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM posts p#{idx}
|
|
WHERE p#{idx}.topic_id = topics.id AND p#{idx}.user_id = #{uid} AND p#{idx}.deleted_at IS NULL #{whisper_condition("p#{idx}")}
|
|
LIMIT 1
|
|
)
|
|
SQL
|
|
end
|
|
else
|
|
not_sql = prefix == "-" ? "NOT" : ""
|
|
@scope = @scope.where(<<~SQL, user_ids: user_ids)
|
|
topics.id #{not_sql} IN (
|
|
SELECT DISTINCT p.topic_id
|
|
FROM posts p
|
|
WHERE p.user_id IN (:user_ids)
|
|
AND p.deleted_at IS NULL
|
|
#{whisper_condition("p")}
|
|
)
|
|
SQL
|
|
end
|
|
end
|
|
end
|
|
|
|
# group:staff,moderators => any of the groups have participation
|
|
# group:staff+moderators => both groups have participation
|
|
def filter_groups(values:)
|
|
values.each do |value|
|
|
require_all, group_names = calculate_all_or_any(value)
|
|
|
|
if group_names.empty?
|
|
@scope = @scope.none
|
|
next
|
|
end
|
|
|
|
group_ids =
|
|
Group
|
|
.visible_groups(@guardian.user)
|
|
.members_visible_groups(@guardian.user)
|
|
.where("lower(name) IN (?)", group_names)
|
|
.pluck(:id)
|
|
|
|
if group_ids.empty?
|
|
@scope = @scope.none
|
|
next
|
|
end
|
|
|
|
if require_all
|
|
if group_ids.length < group_names.length
|
|
@scope = @scope.none
|
|
next
|
|
end
|
|
|
|
group_ids.each_with_index { |gid, idx| @scope = @scope.where(<<~SQL) }
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM posts pg#{idx}
|
|
JOIN group_users gu#{idx} ON gu#{idx}.user_id = pg#{idx}.user_id
|
|
WHERE pg#{idx}.topic_id = topics.id AND gu#{idx}.group_id = #{gid} #{whisper_condition("pg#{idx}")}
|
|
)
|
|
SQL
|
|
else
|
|
@scope = @scope.where(<<~SQL, group_ids: group_ids)
|
|
topics.id IN (
|
|
SELECT DISTINCT p.topic_id
|
|
FROM posts p
|
|
JOIN group_users gu ON gu.user_id = p.user_id
|
|
WHERE gu.group_id IN (:group_ids)
|
|
#{whisper_condition("p")}
|
|
)
|
|
SQL
|
|
end
|
|
end
|
|
end
|
|
|
|
def filter_categories(values:)
|
|
category_slugs = {
|
|
include: {
|
|
with_subcategories: [],
|
|
without_subcategories: [],
|
|
},
|
|
exclude: {
|
|
with_subcategories: [],
|
|
without_subcategories: [],
|
|
},
|
|
}
|
|
|
|
values.each do |key_prefix, value|
|
|
exclude_categories = key_prefix&.include?("-")
|
|
exclude_subcategories = key_prefix&.include?("=")
|
|
|
|
value
|
|
.scan(
|
|
/\A(?<category_slugs>([\p{L}\p{N}\-:]+)(?<delimiter>[,])?([\p{L}\p{N}\-:]+)?(\k<delimiter>[\p{L}\p{N}\-:]+)*)\z/,
|
|
)
|
|
.each do |category_slugs_match, delimiter|
|
|
slugs = category_slugs_match.split(delimiter)
|
|
type = exclude_categories ? :exclude : :include
|
|
subcategory_type = exclude_subcategories ? :without_subcategories : :with_subcategories
|
|
category_slugs[type][subcategory_type].concat(slugs)
|
|
end
|
|
end
|
|
|
|
include_category_ids = []
|
|
|
|
if category_slugs[:include][:without_subcategories].present?
|
|
include_category_ids =
|
|
get_category_ids_from_slugs(
|
|
category_slugs[:include][:without_subcategories],
|
|
exclude_subcategories: true,
|
|
)
|
|
end
|
|
|
|
if category_slugs[:include][:with_subcategories].present?
|
|
include_category_ids.concat(
|
|
get_category_ids_from_slugs(
|
|
category_slugs[:include][:with_subcategories],
|
|
exclude_subcategories: false,
|
|
),
|
|
)
|
|
end
|
|
|
|
if include_category_ids.present?
|
|
@scope = @scope.where("topics.category_id IN (?)", include_category_ids)
|
|
elsif category_slugs[:include].values.flatten.present?
|
|
@scope = @scope.none
|
|
return
|
|
end
|
|
|
|
exclude_category_ids = []
|
|
|
|
if category_slugs[:exclude][:without_subcategories].present?
|
|
exclude_category_ids =
|
|
get_category_ids_from_slugs(
|
|
category_slugs[:exclude][:without_subcategories],
|
|
exclude_subcategories: true,
|
|
)
|
|
end
|
|
|
|
if category_slugs[:exclude][:with_subcategories].present?
|
|
exclude_category_ids.concat(
|
|
get_category_ids_from_slugs(
|
|
category_slugs[:exclude][:with_subcategories],
|
|
exclude_subcategories: false,
|
|
),
|
|
)
|
|
end
|
|
|
|
# Use `NOT EXISTS` instead of `NOT IN` to avoid performance issues with large arrays.
|
|
@scope = @scope.where(<<~SQL) if exclude_category_ids.present?
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM unnest(array[#{exclude_category_ids.join(",")}]) AS excluded_categories(category_id)
|
|
WHERE topics.category_id IS NULL OR excluded_categories.category_id = topics.category_id
|
|
)
|
|
SQL
|
|
end
|
|
|
|
def filter_created_by(names:)
|
|
if names.include?("me") && @guardian.authenticated?
|
|
names = names.map { |n| n == "me" ? @guardian.user.username_lower : n }
|
|
end
|
|
|
|
if (user_ids = User.where("username_lower IN (?)", names.map(&:downcase)).pluck(:id)) &&
|
|
user_ids.any?
|
|
@scope = @scope.joins(:user).where(user_id: user_ids)
|
|
return
|
|
end
|
|
|
|
if (
|
|
group_ids =
|
|
Group
|
|
.visible_groups(@guardian.user)
|
|
.members_visible_groups(@guardian.user)
|
|
.where("lower(name) IN (?)", names.map(&:downcase))
|
|
.pluck(:id)
|
|
) && group_ids.any?
|
|
@scope =
|
|
@scope
|
|
.joins(:user)
|
|
.joins("INNER JOIN group_users ON group_users.user_id = users.id")
|
|
.where("group_users.group_id IN (?)", group_ids)
|
|
.distinct(:id)
|
|
return
|
|
end
|
|
|
|
@scope = @scope.none
|
|
end
|
|
|
|
def apply_custom_filter!(scope:, filter_name:, values:)
|
|
values.dup.each do |value|
|
|
custom_key = "#{filter_name}:#{value}"
|
|
if custom_match =
|
|
DiscoursePluginRegistry.custom_filter_mappings.find { |hash| hash.key?(custom_key) }
|
|
scope = custom_match[custom_key].call(scope, custom_key, @guardian) || scope
|
|
values.delete(value)
|
|
end
|
|
end
|
|
scope
|
|
end
|
|
|
|
def ensure_topic_users_reference!
|
|
if @guardian.authenticated?
|
|
if !@loaded_topic_users_reference
|
|
@scope =
|
|
@scope.joins(
|
|
"LEFT JOIN topic_users tu ON tu.topic_id = topics.id
|
|
AND tu.user_id = #{@guardian.user.id.to_i}",
|
|
)
|
|
@loaded_topic_users_reference = true
|
|
end
|
|
end
|
|
end
|
|
|
|
def topic_user_scope
|
|
@scope.where(
|
|
"tu.notification_level IN (:topic_notification_levels)",
|
|
topic_notification_levels: @topic_notification_levels.to_a,
|
|
)
|
|
end
|
|
|
|
def watching_first_post_scope
|
|
TopicQuery.watching_first_post_filter(@scope, @guardian.user)
|
|
end
|
|
|
|
def combine_scopes_with_or(scope1, scope2)
|
|
@scope.joins_values.concat(scope1.joins_values, scope2.joins_values).uniq!
|
|
@scope.merge(scope1.or(scope2))
|
|
end
|
|
|
|
def filter_in(values:)
|
|
values.uniq!
|
|
|
|
# handle edge case of comma-separated values
|
|
values.map! { |value| value.split(",") }.flatten!
|
|
|
|
if values.delete("pinned")
|
|
@scope =
|
|
@scope.where(
|
|
"topics.pinned_at IS NOT NULL AND topics.pinned_until > topics.pinned_at AND ? < topics.pinned_until",
|
|
Time.zone.now,
|
|
)
|
|
end
|
|
|
|
@scope = apply_custom_filter!(scope: @scope, filter_name: "in", values:)
|
|
|
|
if @guardian.authenticated?
|
|
if values.delete("new-topics")
|
|
ensure_topic_users_reference!
|
|
@scope =
|
|
TopicQuery.new_filter(
|
|
@scope,
|
|
treat_as_new_topic_start_date: @guardian.user.user_option.treat_as_new_topic_start_date,
|
|
)
|
|
end
|
|
|
|
if values.delete("new-replies")
|
|
ensure_topic_users_reference!
|
|
@scope = TopicQuery.unread_filter(@scope, whisperer: @guardian.user.whisperer?)
|
|
end
|
|
|
|
if values.delete("new")
|
|
ensure_topic_users_reference!
|
|
new_topics =
|
|
TopicQuery.new_filter(
|
|
@scope,
|
|
treat_as_new_topic_start_date: @guardian.user.user_option.treat_as_new_topic_start_date,
|
|
)
|
|
unread_topics = TopicQuery.unread_filter(@scope, whisperer: @guardian.user.whisperer?)
|
|
@scope = combine_scopes_with_or(new_topics, unread_topics)
|
|
end
|
|
|
|
if values.delete("unseen")
|
|
ensure_topic_users_reference!
|
|
@scope = TopicQuery.unseen_filter(@scope, @guardian.user)
|
|
end
|
|
|
|
if values.delete("bookmarked")
|
|
ensure_topic_users_reference!
|
|
@scope = @scope.where("tu.bookmarked")
|
|
end
|
|
|
|
if values.present?
|
|
values.each do |value|
|
|
value
|
|
.split(",")
|
|
.each do |topic_notification_level|
|
|
if level = TopicUser.notification_levels[topic_notification_level.to_sym]
|
|
@topic_notification_levels << level
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# watching_first_post is a category/tag-level notification, not a topic-level one
|
|
# We need to handle it separately from regular notification levels and combine with OR
|
|
has_watching_first_post = values.delete("watching_first_post")
|
|
|
|
if has_watching_first_post && @topic_notification_levels.present?
|
|
ensure_topic_users_reference!
|
|
@scope = combine_scopes_with_or(topic_user_scope, watching_first_post_scope)
|
|
elsif has_watching_first_post
|
|
@scope = @scope.merge(watching_first_post_scope)
|
|
elsif @topic_notification_levels.present?
|
|
ensure_topic_users_reference!
|
|
@scope = @scope.merge(topic_user_scope)
|
|
end
|
|
elsif values.present?
|
|
@scope = @scope.none
|
|
end
|
|
end
|
|
|
|
def get_category_ids_from_slugs(slugs, exclude_subcategories: false)
|
|
category_ids = Category.ids_from_slugs(slugs)
|
|
|
|
category_ids =
|
|
Category
|
|
.where(id: category_ids)
|
|
.filter { |category| @guardian.can_see_category?(category) }
|
|
.map(&:id)
|
|
|
|
if !exclude_subcategories
|
|
category_ids = category_ids.flat_map { |category_id| Category.subcategory_ids(category_id) }
|
|
end
|
|
|
|
category_ids
|
|
end
|
|
|
|
# Accepts an array of tag names and returns an array of resolved tag IDs.
|
|
# Synonym tags are resolved to their target tag ID.
|
|
def tag_ids_from_tag_names(tag_names)
|
|
DiscourseTagging
|
|
.filter_visible(Tag, @guardian)
|
|
.where_name(tag_names)
|
|
.pluck(:id, :target_tag_id)
|
|
.map { |id, target_id| target_id || id }
|
|
.uniq
|
|
end
|
|
|
|
def filter_tag_groups(values:)
|
|
values.each do |key_prefix, tag_groups_value|
|
|
tag_group_name = strip_quotes(tag_groups_value)
|
|
tag_group_ids = TagGroup.visible(@guardian).where_name(tag_group_name).pluck(:id)
|
|
exclude_clause = "NOT" if key_prefix == "-"
|
|
filter =
|
|
"tags.id #{exclude_clause} IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?))"
|
|
|
|
query =
|
|
if exclude_clause.present?
|
|
@scope
|
|
.joins("LEFT JOIN topic_tags ON topic_tags.topic_id = topics.id")
|
|
.joins("LEFT JOIN tags ON tags.id = topic_tags.tag_id")
|
|
.where("tags.id IS NULL OR #{filter}", tag_group_ids)
|
|
else
|
|
@scope.joins(:tags).where(filter, tag_group_ids)
|
|
end
|
|
|
|
@scope = query.distinct(:id)
|
|
end
|
|
end
|
|
|
|
def strip_quotes(value)
|
|
value.gsub(/\A["']|["']\z/, "")
|
|
end
|
|
|
|
def filter_tags(values:)
|
|
return if !SiteSetting.tagging_enabled?
|
|
|
|
values.each do |key_prefix, value|
|
|
break if key_prefix && key_prefix != "-"
|
|
|
|
value.scan(
|
|
/\A(?<tag_names>([\p{N}\p{L}\-_]+)(?<delimiter>[,+])?([\p{N}\p{L}\-_]+)?(\k<delimiter>[\p{N}\p{L}\-_]+)*)\z/,
|
|
) do |tag_names, delimiter|
|
|
match_all =
|
|
if delimiter == ","
|
|
false
|
|
else
|
|
true
|
|
end
|
|
|
|
tags = tag_names.split(delimiter)
|
|
tag_ids = tag_ids_from_tag_names(tags)
|
|
|
|
case [key_prefix, match_all]
|
|
in ["-", false]
|
|
exclude_topics_with_any_tags(tag_ids)
|
|
in ["-", true]
|
|
exclude_topics_with_all_tags(tag_ids)
|
|
in [nil, false]
|
|
include_topics_with_any_tags(tag_ids)
|
|
in [nil, true]
|
|
has_invalid_tags = tag_ids.length < tags.length
|
|
|
|
if has_invalid_tags
|
|
@scope = @scope.none
|
|
else
|
|
include_topics_with_all_tags(tag_ids)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def topic_tags_alias
|
|
@topic_tags_alias ||= 0
|
|
"tt#{@topic_tags_alias += 1}"
|
|
end
|
|
|
|
def exclude_topics_with_all_tags(tag_ids)
|
|
where_clause = []
|
|
|
|
tag_ids.each do |tag_id|
|
|
sql_alias = "tt#{topic_tags_alias}"
|
|
|
|
@scope =
|
|
@scope.joins(
|
|
"LEFT JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag_id}",
|
|
)
|
|
|
|
where_clause << "#{sql_alias}.topic_id IS NULL"
|
|
end
|
|
|
|
@scope = @scope.where(where_clause.join(" OR "))
|
|
end
|
|
|
|
def exclude_topics_with_any_tags(tag_ids)
|
|
@scope =
|
|
@scope.where(
|
|
"topics.id NOT IN (SELECT DISTINCT topic_id FROM topic_tags WHERE topic_tags.tag_id IN (?))",
|
|
tag_ids,
|
|
)
|
|
end
|
|
|
|
def include_topics_with_all_tags(tag_ids)
|
|
tag_ids.each do |tag_id|
|
|
sql_alias = topic_tags_alias
|
|
@scope =
|
|
@scope.joins(
|
|
"INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag_id}",
|
|
)
|
|
end
|
|
end
|
|
|
|
def include_topics_with_any_tags(tag_ids)
|
|
sql_alias = topic_tags_alias
|
|
|
|
@scope =
|
|
@scope
|
|
.joins("INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id")
|
|
.where("#{sql_alias}.tag_id IN (?)", tag_ids)
|
|
.distinct(:id)
|
|
end
|
|
|
|
def filter_locale(values:)
|
|
include_locales = []
|
|
exclude_locales = []
|
|
|
|
values.each do |key_prefix, value|
|
|
locales = value.split(",").map(&:strip).reject(&:blank?)
|
|
next if locales.empty?
|
|
|
|
if key_prefix == "-"
|
|
exclude_locales.concat(locales)
|
|
else
|
|
include_locales.concat(locales)
|
|
end
|
|
end
|
|
|
|
@scope = @scope.where(locale: include_locales) if include_locales.present?
|
|
|
|
if exclude_locales.present?
|
|
@scope = @scope.where("topics.locale IS NULL OR topics.locale NOT IN (?)", exclude_locales)
|
|
end
|
|
end
|
|
|
|
ORDER_BY_MAPPINGS = {
|
|
"activity" => {
|
|
column: "topics.bumped_at",
|
|
},
|
|
"category" => {
|
|
column: "categories.name",
|
|
scope: -> { @scope.joins(:category) },
|
|
},
|
|
"created" => {
|
|
column: "topics.created_at",
|
|
},
|
|
"latest-post" => {
|
|
column: "topics.last_posted_at",
|
|
},
|
|
"likes" => {
|
|
column: "topics.like_count",
|
|
},
|
|
"likes-op" => {
|
|
column: "first_posts.like_count",
|
|
scope: -> { joins_first_posts(@scope) },
|
|
},
|
|
"posters" => {
|
|
column: "topics.participant_count",
|
|
},
|
|
"title" => {
|
|
column: "LOWER(topics.title)",
|
|
},
|
|
"views" => {
|
|
column: "topics.views",
|
|
},
|
|
"hot" => {
|
|
column: "COALESCE(topic_hot_scores.score, 0)",
|
|
scope: -> do
|
|
@scope.joins("LEFT JOIN topic_hot_scores ON topic_hot_scores.topic_id = topics.id")
|
|
end,
|
|
},
|
|
"read" => {
|
|
column: "tu.last_visited_at",
|
|
scope: -> do
|
|
if @guardian.authenticated?
|
|
ensure_topic_users_reference!
|
|
@scope.where.not(tu: { last_visited_at: nil })
|
|
else
|
|
# make sure this works for anon (particularly selection)
|
|
@scope.joins("LEFT JOIN topic_users tu ON 1 = 0")
|
|
end
|
|
end,
|
|
},
|
|
}
|
|
private_constant :ORDER_BY_MAPPINGS
|
|
|
|
ORDER_BY_REGEXP = /\A(?<order_by>#{ORDER_BY_MAPPINGS.keys.join("|")})(?<asc>-asc)?\z/
|
|
private_constant :ORDER_BY_REGEXP
|
|
|
|
def order_by(values:)
|
|
values.each do |value|
|
|
# If the order by value is not recognized, check if it is a custom filter.
|
|
match_data = value.match(ORDER_BY_REGEXP)
|
|
if match_data && column_name = ORDER_BY_MAPPINGS.dig(match_data[:order_by], :column)
|
|
if scope = ORDER_BY_MAPPINGS.dig(match_data[:order_by], :scope)
|
|
@scope = instance_exec(&scope)
|
|
end
|
|
@scope = @scope.order("#{column_name} #{match_data[:asc] ? "ASC" : "DESC"}")
|
|
else
|
|
match_data = value.match(/^(?<column>.*?)(?:-(?<asc>asc))?$/)
|
|
key = "order:#{match_data[:column]}"
|
|
if custom_match =
|
|
DiscoursePluginRegistry.custom_filter_mappings.find { |hash| hash.key?(key) }
|
|
dir = match_data[:asc] ? "ASC" : "DESC"
|
|
@scope = custom_match[key].call(@scope, dir, @guardian) || @scope
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def joins_first_posts(scope)
|
|
scope.joins(
|
|
"INNER JOIN posts AS first_posts ON first_posts.topic_id = topics.id AND first_posts.post_number = 1",
|
|
)
|
|
end
|
|
|
|
def whisper_condition(table_alias)
|
|
if @guardian.can_see_whispers?
|
|
""
|
|
else
|
|
"AND #{table_alias}.post_type != #{Post.types[:whisper]}"
|
|
end
|
|
end
|
|
end
|