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:
parent
d37ecd4764
commit
fd34032db2
|
@ -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)
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue