DEV: Support filter for topics in specific subcategories on /filter (#20927)

This commit adds support for filtering for topics in specific
subcategories via the categories filter query language.

For example: `category:documentation:admins` will filter for topics and
subcategory topics in
the category with slug "admins" whose parent category has the slug
"documentation".

The `=` prefix can also be used such that
`=category:documentation:admins` will exclude subcategory topics of the
category with slug "admins" whose parent category has the slug
"documentation".
This commit is contained in:
Alan Guo Xiang Tan 2023-04-03 18:36:59 +08:00 committed by GitHub
parent d37ecd4764
commit fd34032db2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 268 additions and 4 deletions

View File

@ -215,6 +215,48 @@ class Category < ActiveRecord::Base
Category.reset_topic_ids_cache
end
# Accepts an array of slugs with each item in the array
# Returns the category ids of the last slug in the array. The slugs array has to follow the proper category
# nesting hierarchy. If any of the slug in the array is invalid or if the slugs array does not follow the proper
# category nesting hierarchy, nil is returned.
#
# When only a single slug is provided, the category id of all the categories with that slug is returned.
def self.ids_from_slugs(slugs)
return [] if slugs.blank?
params = {}
params_index = 0
sqls =
slugs.map do |slug|
category_slugs = slug.split(":").first(SiteSetting.max_category_nesting)
sql = ""
if category_slugs.length == 1
params[:"slug_#{params_index}"] = category_slugs.first
sql = "SELECT id FROM categories WHERE slug = :slug_#{params_index}"
params_index += 1
else
category_slugs.each_with_index do |category_slug, index|
params[:"slug_#{params_index}"] = category_slug
sql =
if index == 0
"SELECT id FROM categories WHERE slug = :slug_#{params_index} AND parent_category_id IS NULL"
else
"SELECT id FROM categories WHERE parent_category_id = (#{sql}) AND slug = :slug_#{params_index}"
end
params_index += 1
end
end
sql
end
DB.query_single(sqls.join("\nUNION ALL\n"), params)
end
@@subcategory_ids = DistributedCache.new("subcategory_ids")
def self.subcategory_ids(category_id)

View File

@ -10,7 +10,7 @@ class TopicsFilter
return @scope if query_string.blank?
query_string.scan(
/(?<key_prefix>[-=])?(?<key>\w+):(?<value>[^:\s]+)/,
/(?<key_prefix>[-=])?(?<key>\w+):(?<value>[^\s]+)/,
) do |key_prefix, key, value|
case key
when "status"
@ -37,7 +37,7 @@ class TopicsFilter
end
when "category", "categories"
value.scan(
/^(?<category_slugs>([a-zA-Z0-9\-]+)(?<delimiter>[,])?([a-zA-Z0-9\-]+)?(\k<delimiter>[a-zA-Z0-9\-]+)*)$/,
/^(?<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 != "="
@ -81,13 +81,16 @@ class TopicsFilter
private
def filter_categories(category_slugs:, exclude_subcategories: false)
category_ids = Category.ids_from_slugs(category_slugs)
category_ids =
Category
.where(slug: category_slugs)
.where(id: category_ids)
.filter { |category| @guardian.can_see_category?(category) }
.map(&:id)
return @scope.none if category_ids.length != category_slugs.length
# 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
category_ids = category_ids.flat_map { |category_id| Category.subcategory_ids(category_id) }

View File

@ -159,6 +159,152 @@ RSpec.describe TopicsFilter do
).to contain_exactly(topic_in_category.id, topic_in_category2.id)
end
end
describe "when multiple categories have subcategories with the same name" do
fab!(:category_subcategory) do
Fabricate(:category, parent_category: category, name: "subcategory")
end
fab!(:category2_subcategory) do
Fabricate(:category, parent_category: category2, name: "subcategory")
end
fab!(:topic_in_category_subcategory) { Fabricate(:topic, category: category_subcategory) }
fab!(:topic_in_category2_subcategory) { Fabricate(:topic, category: category2_subcategory) }
describe "when query string is `category:subcategory`" do
it "should return topics from subcategories of both categories" do
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("category:subcategory")
.pluck(:id),
).to contain_exactly(
topic_in_category_subcategory.id,
topic_in_category2_subcategory.id,
)
end
end
describe "when query string is `category:category:subcategory`" do
it "should return topics from subcategories of the specified category" do
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("category:category:subcategory")
.pluck(:id),
).to contain_exactly(topic_in_category_subcategory.id)
end
end
describe "when query string is `category:category2:subcategory`" do
it "should return topics from subcategories of the specified category" do
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("category:category2:subcategory")
.pluck(:id),
).to contain_exactly(topic_in_category2_subcategory.id)
end
end
describe "when query string is `category:category:subcategory,category2:subcategory`" do
it "should return topics from either subcategory" do
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("category:category:subcategory,category2:subcategory")
.pluck(:id),
).to contain_exactly(
topic_in_category_subcategory.id,
topic_in_category2_subcategory.id,
)
end
end
describe "when max category nesting is 3" do
fab!(:category_subcategory_subcategory) do
SiteSetting.max_category_nesting = 3
Fabricate(:category, parent_category: category_subcategory, name: "sub-subcategory")
end
fab!(:category2_subcategory_subcategory) do
SiteSetting.max_category_nesting = 3
Fabricate(:category, parent_category: category2_subcategory, name: "sub-subcategory")
end
fab!(:topic_in_category_subcategory_subcategory) do
Fabricate(:topic, category: category_subcategory_subcategory)
end
fab!(:topic_in_category2_subcategory_subcategory) do
Fabricate(:topic, category: category2_subcategory_subcategory)
end
before { SiteSetting.max_category_nesting = 3 }
describe "when query string is `category:category:subcategory:sub-subcategory`" do
it "return topics from category with slug 'sub-subcategory' with the category ancestor chain of 'subcategory' and 'category'" do
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("category:category:subcategory:sub-subcategory")
.pluck(:id),
).to contain_exactly(topic_in_category_subcategory_subcategory.id)
end
end
describe "when query string is `=category:category2:subcategory`" do
it "return topics from category with slug 'subcategory' with the category ancestor chain of 'category2'" do
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("=category:category2:subcategory")
.pluck(:id),
).to contain_exactly(topic_in_category2_subcategory.id)
end
end
describe "when query string is `category:category2:subcategory`" do
it "return topics and subcategories topics from category with slug 'subcategory' with the category ancestor chain of 'category2'" do
category2_subcategory_subcategory2 =
Fabricate(
:category,
parent_category: category2_subcategory,
name: "sub-subcategory2",
)
topic_in_category2_subcategory_subcategory2 =
Fabricate(:topic, category: category2_subcategory_subcategory2)
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("category:category2:subcategory")
.pluck(:id),
).to contain_exactly(
topic_in_category2_subcategory.id,
topic_in_category2_subcategory_subcategory.id,
topic_in_category2_subcategory_subcategory2.id,
)
end
end
describe "when query string is `category:sub-subcategory`" do
it "return topics from either category with slug 'sub-subcategory'" do
expect(
TopicsFilter
.new(guardian: Guardian.new)
.filter_from_query_string("category:sub-subcategory")
.pluck(:id),
).to contain_exactly(
topic_in_category_subcategory_subcategory.id,
topic_in_category2_subcategory_subcategory.id,
)
end
end
end
end
end
describe "when filtering by status" do

View File

@ -1352,4 +1352,77 @@ RSpec.describe Category do
expect(SiteSetting.general_category_id).to be < 1
end
end
describe ".ids_from_slugs" do
fab!(:category) { Fabricate(:category, slug: "category") }
fab!(:category2) { Fabricate(:category, slug: "category2") }
fab!(:subcategory) { Fabricate(:category, parent_category: category, slug: "subcategory") }
fab!(:subcategory2) { Fabricate(:category, parent_category: category2, slug: "subcategory") }
it "returns [] when inputs is []" do
expect(Category.ids_from_slugs([])).to eq([])
end
it 'returns the ids of category when input is ["category"]' do
expect(Category.ids_from_slugs(%w[category])).to contain_exactly(category.id)
end
it 'returns the ids of subcategory when input is ["category:subcategory"]' do
expect(Category.ids_from_slugs(%w[category:subcategory])).to contain_exactly(subcategory.id)
end
it 'returns the ids of subcategory2 when input is ["category2:subcategory"]' do
expect(Category.ids_from_slugs(%w[category2:subcategory])).to contain_exactly(subcategory2.id)
end
it "returns the ids of category and category2 when input is ['category', 'category2']" do
expect(Category.ids_from_slugs(%w[category category2])).to contain_exactly(
category.id,
category2.id,
)
end
it "returns the ids of subcategory and subcategory2 when input is ['category:subcategory', 'category2:subcategory']" do
expect(
Category.ids_from_slugs(%w[category:subcategory category2:subcategory]),
).to contain_exactly(subcategory.id, subcategory2.id)
end
it "returns the ids of subcategory when input is ['category:subcategory', 'invalid:subcategory']" do
expect(
Category.ids_from_slugs(%w[category:subcategory invalid:subcategory]),
).to contain_exactly(subcategory.id)
end
it 'returns the ids of sub-subcategory when input is ["category:subcategory:sub-subcategory"] and maximum category nesting is 3' do
SiteSetting.max_category_nesting = 3
sub_subcategory = Fabricate(:category, parent_category: subcategory, slug: "sub-subcategory")
expect(Category.ids_from_slugs(%w[category:subcategory:sub-subcategory])).to contain_exactly(
sub_subcategory.id,
)
end
it 'returns nil when input is ["category:invalid-slug:sub-subcategory"] and maximum category nesting is 3' do
SiteSetting.max_category_nesting = 3
sub_subcategory = Fabricate(:category, parent_category: subcategory, slug: "sub-subcategory")
expect(Category.ids_from_slugs(%w[category:invalid-slug:sub-subcategory])).to eq([])
end
it 'returns the ids of subcategory when input is ["category:subcategory:sub-subcategory"] but maximum category nesting is 2' do
SiteSetting.max_category_nesting = 2
expect(Category.ids_from_slugs(%w[category:subcategory:sub-subcategory])).to contain_exactly(
subcategory.id,
)
end
it 'returns the ids of subcategory and subcategory2 when input is ["subcategory"]' do
expect(Category.ids_from_slugs(%w[subcategory])).to contain_exactly(
subcategory.id,
subcategory2.id,
)
end
end
end