DEV: Refactor `TopicsFilter` (#21071)
Why this change? Previously `TopicsFilter` was designed in such a way that we act on a filter sequentially based on the order it was matched. However, this made it hard to support filters composition where a similar filter may be present further in the query string. Because of this limitation, I previously introduced a private API `TopicsFilter.register_scope` which allows us to act on a filter only after the entire query string has been scanned. However, I felt that it made the code complicated and hard to reason about. In thie commit, I've changed it such that we scan through the entire query string and group the values of each filter together. This allows us to act on the values of a given filter in one go which I find easier to reason about. This also opens up the possibility for us to ignore certain filters when it has been specified multiple times.
This commit is contained in:
parent
192f4149db
commit
5b1306cb54
|
@ -1,66 +1,51 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TopicsFilter
|
class TopicsFilter
|
||||||
def initialize(guardian:, scope: Topic)
|
attr_reader :topic_notification_levels
|
||||||
|
|
||||||
|
def initialize(guardian:, scope: Topic.all)
|
||||||
@guardian = guardian
|
@guardian = guardian
|
||||||
@scope = scope
|
@scope = scope
|
||||||
@notification_levels = Set.new
|
@topic_notification_levels = Set.new
|
||||||
@registered_scopes = {}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def topic_notification_levels
|
FILTER_ALIASES = { "categories" => "category" }
|
||||||
@registered_scopes.dig(:topic_notification_levels, :params, :notification_levels) || []
|
private_constant :FILTER_ALIASES
|
||||||
end
|
|
||||||
|
|
||||||
def filter_from_query_string(query_string)
|
def filter_from_query_string(query_string)
|
||||||
return @scope if query_string.blank?
|
return @scope if query_string.blank?
|
||||||
|
|
||||||
|
filters = {}
|
||||||
|
|
||||||
query_string.scan(
|
query_string.scan(
|
||||||
/(?<key_prefix>[-=])?(?<key>[\w-]+):(?<value>[^\s]+)/,
|
/(?<key_prefix>[-=])?(?<key>[\w-]+):(?<value>[^\s]+)/,
|
||||||
) do |key_prefix, key, value|
|
) do |key_prefix, key, value|
|
||||||
case key
|
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"]
|
||||||
|
|
||||||
|
case filter
|
||||||
|
when "category"
|
||||||
|
filter_categories(values: key_prefixes.zip(values))
|
||||||
when "created-by"
|
when "created-by"
|
||||||
filter_created_by_user(usernames: value.split(","))
|
filter_created_by_user(usernames: values.flat_map { |value| value.split(",") })
|
||||||
when "in"
|
when "in"
|
||||||
@scope = filter_state(state: value)
|
filter_in(values: values)
|
||||||
when "status"
|
when "status"
|
||||||
@scope = filter_status(status: value)
|
values.each { |status| @scope = filter_status(status: status) }
|
||||||
when "tags"
|
when "tags"
|
||||||
value.scan(
|
filter_tags(values: key_prefixes.zip(values))
|
||||||
/^(?<tag_names>([a-zA-Z0-9\-]+)(?<delimiter>[,+])?([a-zA-Z0-9\-]+)?(\k<delimiter>[a-zA-Z0-9\-]+)*)$/,
|
|
||||||
) do |tag_names, delimiter|
|
|
||||||
break if key_prefix && key_prefix != "-"
|
|
||||||
|
|
||||||
match_all =
|
|
||||||
if delimiter == ","
|
|
||||||
false
|
|
||||||
else
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
@scope =
|
|
||||||
filter_tags(
|
|
||||||
tag_names: tag_names.split(delimiter),
|
|
||||||
exclude: key_prefix.presence,
|
|
||||||
match_all: match_all,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
when "category", "categories"
|
|
||||||
value.scan(
|
|
||||||
/^(?<category_slugs>([a-zA-Z0-9\-:]+)(?<delimiter>[,])?([a-zA-Z0-9\-:]+)?(\k<delimiter>[a-zA-Z0-9\-:]+)*)$/,
|
|
||||||
) do |category_slugs, delimiter|
|
|
||||||
break if key_prefix && key_prefix != "="
|
|
||||||
|
|
||||||
@scope =
|
|
||||||
filter_categories(
|
|
||||||
category_slugs: category_slugs.split(delimiter),
|
|
||||||
exclude_subcategories: key_prefix.presence,
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
@registered_scopes.each_value { |value| @scope = value[:block].call(*value[:params].values) }
|
|
||||||
|
|
||||||
@scope
|
@scope
|
||||||
end
|
end
|
||||||
|
@ -92,63 +77,108 @@ class TopicsFilter
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def filter_created_by_user(usernames:)
|
def filter_categories(values:)
|
||||||
register_scope(
|
exclude_subcategories_category_slugs = []
|
||||||
key: :created_by_user,
|
include_subcategories_category_slugs = []
|
||||||
params: {
|
|
||||||
usernames: usernames.map(&:downcase),
|
values.each do |key_prefix, value|
|
||||||
},
|
break if key_prefix && key_prefix != "="
|
||||||
) do |usernames_lower|
|
|
||||||
@scope.joins(:user).where("users.username_lower IN (?)", usernames_lower)
|
value
|
||||||
|
.scan(
|
||||||
|
/^(?<category_slugs>([a-zA-Z0-9\-:]+)(?<delimiter>[,])?([a-zA-Z0-9\-:]+)?(\k<delimiter>[a-zA-Z0-9\-:]+)*)$/,
|
||||||
|
)
|
||||||
|
.each do |category_slugs, delimiter|
|
||||||
|
(
|
||||||
|
if key_prefix.presence
|
||||||
|
exclude_subcategories_category_slugs
|
||||||
|
else
|
||||||
|
include_subcategories_category_slugs
|
||||||
|
end
|
||||||
|
).concat(category_slugs.split(delimiter))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_state(state:)
|
category_ids = []
|
||||||
case state
|
|
||||||
when "pinned"
|
if exclude_subcategories_category_slugs.present?
|
||||||
|
category_ids =
|
||||||
|
get_category_ids_from_slugs(
|
||||||
|
exclude_subcategories_category_slugs,
|
||||||
|
exclude_subcategories: true,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if include_subcategories_category_slugs.present?
|
||||||
|
category_ids.concat(
|
||||||
|
get_category_ids_from_slugs(
|
||||||
|
include_subcategories_category_slugs,
|
||||||
|
exclude_subcategories: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if category_ids.present?
|
||||||
|
@scope = @scope.where("topics.category_id IN (?)", category_ids)
|
||||||
|
elsif exclude_subcategories_category_slugs.present? ||
|
||||||
|
include_subcategories_category_slugs.present?
|
||||||
|
@scope = @scope.none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_created_by_user(usernames:)
|
||||||
|
@scope =
|
||||||
|
@scope.joins(:user).where(
|
||||||
|
"users.username_lower IN (:usernames)",
|
||||||
|
usernames: usernames.map(&:downcase),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_in(values:)
|
||||||
|
values.uniq!
|
||||||
|
|
||||||
|
if values.delete("pinned")
|
||||||
|
@scope =
|
||||||
@scope.where(
|
@scope.where(
|
||||||
"topics.pinned_at IS NOT NULL AND topics.pinned_until > topics.pinned_at AND ? < topics.pinned_until",
|
"topics.pinned_at IS NOT NULL AND topics.pinned_until > topics.pinned_at AND ? < topics.pinned_until",
|
||||||
Time.zone.now,
|
Time.zone.now,
|
||||||
)
|
)
|
||||||
when "bookmarked"
|
end
|
||||||
return @scope.none unless @guardian.user
|
|
||||||
|
|
||||||
|
if @guardian.user
|
||||||
|
if values.delete("bookmarked")
|
||||||
|
@scope =
|
||||||
@scope.joins(:topic_users).where(
|
@scope.joins(:topic_users).where(
|
||||||
"topic_users.bookmarked AND topic_users.user_id = ?",
|
"topic_users.bookmarked AND topic_users.user_id = ?",
|
||||||
@guardian.user.id,
|
@guardian.user.id,
|
||||||
)
|
)
|
||||||
else
|
end
|
||||||
return @scope.none if !@guardian.user
|
|
||||||
|
|
||||||
topic_notification_levels = Set.new
|
if values.present?
|
||||||
|
values.each do |value|
|
||||||
state
|
value
|
||||||
.split(",")
|
.split(",")
|
||||||
.each do |topic_notification_level|
|
.each do |topic_notification_level|
|
||||||
if level = TopicUser.notification_levels[topic_notification_level.to_sym]
|
if level = TopicUser.notification_levels[topic_notification_level.to_sym]
|
||||||
topic_notification_levels << level
|
@topic_notification_levels << level
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
register_scope(
|
@scope =
|
||||||
key: :topic_notification_levels,
|
|
||||||
params: {
|
|
||||||
notification_levels: topic_notification_levels.to_a,
|
|
||||||
},
|
|
||||||
) do |notification_levels|
|
|
||||||
@scope.joins(:topic_users).where(
|
@scope.joins(:topic_users).where(
|
||||||
"topic_users.notification_level IN (:topic_notification_levels) AND topic_users.user_id = :user_id",
|
"topic_users.notification_level IN (:topic_notification_levels) AND topic_users.user_id = :user_id",
|
||||||
topic_notification_levels: notification_levels,
|
topic_notification_levels: @topic_notification_levels.to_a,
|
||||||
user_id: @guardian.user.id,
|
user_id: @guardian.user.id,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
elsif values.present?
|
||||||
@scope
|
@scope = @scope.none
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_categories(category_slugs:, exclude_subcategories: false)
|
def get_category_ids_from_slugs(slugs, exclude_subcategories: false)
|
||||||
category_ids = Category.ids_from_slugs(category_slugs)
|
category_ids = Category.ids_from_slugs(slugs)
|
||||||
|
|
||||||
category_ids =
|
category_ids =
|
||||||
Category
|
Category
|
||||||
|
@ -156,46 +186,95 @@ class TopicsFilter
|
||||||
.filter { |category| @guardian.can_see_category?(category) }
|
.filter { |category| @guardian.can_see_category?(category) }
|
||||||
.map(&:id)
|
.map(&:id)
|
||||||
|
|
||||||
# Don't return any records if the user does not have access to any of the categories
|
|
||||||
return @scope.none if category_ids.length < category_slugs.length
|
|
||||||
|
|
||||||
if !exclude_subcategories
|
if !exclude_subcategories
|
||||||
category_ids = category_ids.flat_map { |category_id| Category.subcategory_ids(category_id) }
|
category_ids = category_ids.flat_map { |category_id| Category.subcategory_ids(category_id) }
|
||||||
end
|
end
|
||||||
|
|
||||||
register_scope(key: :categories, params: { category_ids: category_ids }) do |in_category_ids|
|
category_ids
|
||||||
@scope.joins(:category).where(
|
|
||||||
"categories.id IN (:category_ids)",
|
|
||||||
category_ids: in_category_ids,
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@scope
|
# Accepts an array of tag names and returns an array of tag ids and the tag ids of aliases for the tag names which the user can see.
|
||||||
end
|
# If a block is given, it will be called with the tag ids and alias tag ids as arguments.
|
||||||
|
def tag_ids_from_tag_names(tag_names)
|
||||||
def filter_tags(tag_names:, match_all: true, exclude: false)
|
tag_ids, alias_tag_ids =
|
||||||
return @scope if !SiteSetting.tagging_enabled?
|
|
||||||
return @scope if tag_names.blank?
|
|
||||||
|
|
||||||
tag_ids =
|
|
||||||
DiscourseTagging
|
DiscourseTagging
|
||||||
.filter_visible(Tag, @guardian)
|
.filter_visible(Tag, @guardian)
|
||||||
.where_name(tag_names)
|
.where_name(tag_names)
|
||||||
.pluck(:id, :target_tag_id)
|
.pluck(:id, :target_tag_id)
|
||||||
|
|
||||||
tag_ids.flatten!
|
tag_ids ||= []
|
||||||
tag_ids.uniq!
|
alias_tag_ids ||= []
|
||||||
tag_ids.compact!
|
|
||||||
|
|
||||||
return @scope.none if match_all && tag_ids.length != tag_names.length
|
yield(tag_ids, alias_tag_ids) if block_given?
|
||||||
return @scope if tag_ids.empty?
|
|
||||||
|
|
||||||
self.send(
|
all_tag_ids = tag_ids.concat(alias_tag_ids)
|
||||||
"#{exclude ? "exclude" : "include"}_topics_with_#{match_all ? "all" : "any"}_tags",
|
all_tag_ids.compact!
|
||||||
tag_ids,
|
all_tag_ids.uniq!
|
||||||
)
|
all_tag_ids
|
||||||
|
end
|
||||||
|
|
||||||
@scope
|
def filter_tags(values:)
|
||||||
|
return if !SiteSetting.tagging_enabled?
|
||||||
|
|
||||||
|
exclude_all_tags = []
|
||||||
|
exclude_any_tags = []
|
||||||
|
include_any_tags = []
|
||||||
|
include_all_tags = []
|
||||||
|
|
||||||
|
values.each do |key_prefix, value|
|
||||||
|
break if key_prefix && key_prefix != "-"
|
||||||
|
|
||||||
|
value.scan(
|
||||||
|
/^(?<tag_names>([a-zA-Z0-9\-]+)(?<delimiter>[,+])?([a-zA-Z0-9\-]+)?(\k<delimiter>[a-zA-Z0-9\-]+)*)$/,
|
||||||
|
) do |tag_names, delimiter|
|
||||||
|
match_all =
|
||||||
|
if delimiter == ","
|
||||||
|
false
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
(
|
||||||
|
case [key_prefix, match_all]
|
||||||
|
in ["-", true]
|
||||||
|
exclude_all_tags
|
||||||
|
in ["-", false]
|
||||||
|
exclude_any_tags
|
||||||
|
in [nil, true]
|
||||||
|
include_all_tags
|
||||||
|
in [nil, false]
|
||||||
|
include_any_tags
|
||||||
|
end
|
||||||
|
).concat(tag_names.split(delimiter))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if exclude_all_tags.present?
|
||||||
|
exclude_topics_with_all_tags(tag_ids_from_tag_names(exclude_all_tags))
|
||||||
|
end
|
||||||
|
|
||||||
|
if exclude_any_tags.present?
|
||||||
|
exclude_topics_with_any_tags(tag_ids_from_tag_names(exclude_any_tags))
|
||||||
|
end
|
||||||
|
|
||||||
|
if include_any_tags.present?
|
||||||
|
include_topics_with_any_tags(tag_ids_from_tag_names(include_any_tags))
|
||||||
|
end
|
||||||
|
|
||||||
|
if include_all_tags.present?
|
||||||
|
has_invalid_tags = false
|
||||||
|
|
||||||
|
all_tag_ids =
|
||||||
|
tag_ids_from_tag_names(include_all_tags) do |tag_ids, _|
|
||||||
|
has_invalid_tags = tag_ids.length < include_all_tags.length
|
||||||
|
end
|
||||||
|
|
||||||
|
if has_invalid_tags
|
||||||
|
@scope = @scope.none
|
||||||
|
else
|
||||||
|
include_topics_with_all_tags(all_tag_ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def topic_tags_alias
|
def topic_tags_alias
|
||||||
|
@ -242,18 +321,4 @@ class TopicsFilter
|
||||||
def include_topics_with_any_tags(tag_ids)
|
def include_topics_with_any_tags(tag_ids)
|
||||||
@scope = @scope.joins(:topic_tags).where("topic_tags.tag_id IN (?)", tag_ids).distinct(:id)
|
@scope = @scope.joins(:topic_tags).where("topic_tags.tag_id IN (?)", tag_ids).distinct(:id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def register_scope(key:, params:, &block)
|
|
||||||
if registered_scope = @registered_scopes[key]
|
|
||||||
registered_scope[:params].each do |param_name, param_value|
|
|
||||||
if param_value.is_a?(Array)
|
|
||||||
registered_scope[:params][param_name].concat(params[param_name]).tap(&:uniq!)
|
|
||||||
else
|
|
||||||
registered_scope[:params][param_name] = params[param_name]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
@registered_scopes[key] = { block: block, params: params }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue