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
|
Category.reset_topic_ids_cache
|
||||||
end
|
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")
|
@@subcategory_ids = DistributedCache.new("subcategory_ids")
|
||||||
|
|
||||||
def self.subcategory_ids(category_id)
|
def self.subcategory_ids(category_id)
|
||||||
|
|
|
@ -10,7 +10,7 @@ class TopicsFilter
|
||||||
return @scope if query_string.blank?
|
return @scope if query_string.blank?
|
||||||
|
|
||||||
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
|
case key
|
||||||
when "status"
|
when "status"
|
||||||
|
@ -37,7 +37,7 @@ class TopicsFilter
|
||||||
end
|
end
|
||||||
when "category", "categories"
|
when "category", "categories"
|
||||||
value.scan(
|
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|
|
) do |category_slugs, delimiter|
|
||||||
break if key_prefix && key_prefix != "="
|
break if key_prefix && key_prefix != "="
|
||||||
|
|
||||||
|
@ -81,13 +81,16 @@ class TopicsFilter
|
||||||
private
|
private
|
||||||
|
|
||||||
def filter_categories(category_slugs:, exclude_subcategories: false)
|
def filter_categories(category_slugs:, exclude_subcategories: false)
|
||||||
|
category_ids = Category.ids_from_slugs(category_slugs)
|
||||||
|
|
||||||
category_ids =
|
category_ids =
|
||||||
Category
|
Category
|
||||||
.where(slug: category_slugs)
|
.where(id: category_ids)
|
||||||
.filter { |category| @guardian.can_see_category?(category) }
|
.filter { |category| @guardian.can_see_category?(category) }
|
||||||
.map(&:id)
|
.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
|
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) }
|
||||||
|
|
|
@ -159,6 +159,152 @@ RSpec.describe TopicsFilter do
|
||||||
).to contain_exactly(topic_in_category.id, topic_in_category2.id)
|
).to contain_exactly(topic_in_category.id, topic_in_category2.id)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
describe "when filtering by status" do
|
describe "when filtering by status" do
|
||||||
|
|
|
@ -1352,4 +1352,77 @@ RSpec.describe Category do
|
||||||
expect(SiteSetting.general_category_id).to be < 1
|
expect(SiteSetting.general_category_id).to be < 1
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
Loading…
Reference in New Issue