FIX: Use unaccent() for category name/slug search (#37622)

Categories with accented names (e.g. "Éditions") were not appearing in
search results, category chooser dropdowns, or hashtag autocomplete when
searching with unaccented terms (e.g. "editions").

PostgreSQL's `ILIKE` and `LOWER()` are case-insensitive but not
accent-insensitive, so queries like `name ILIKE '%editions%'` would not
match "Éditions".

This wraps all category name/slug comparisons with PostgreSQL's
`unaccent()` function across:
- `/categories/search` endpoint
- `/categories/hierarchical_search` endpoint
- Hashtag (#) autocomplete
- `category:` and `#slug` search filters

Introduces `Category.normalize_sql(expr)` helper that wraps SQL
expressions with `lower(unaccent(...))` to centralize the normalization
logic and make it easier to extend in the future.

On the frontend, adds a shared `removeAccents()` utility using NFD
normalization for client-side `Category.search()` and search filter
suggestions.

Ref - https://meta.discourse.org/t/395355
This commit is contained in:
Régis Hanol
2026-02-12 08:43:23 +01:00
committed by GitHub
parent f065202715
commit 1a2f9f311e
13 changed files with 139 additions and 26 deletions
+8 -2
View File
@@ -403,7 +403,13 @@ class CategoriesController < ApplicationController
categories = Category.secured(guardian)
if term.present? && words = term.split
words.each { |word| categories = categories.where("name ILIKE ?", "%#{word}%") }
words.each do |word|
categories =
categories.where(
"#{Category.normalize_sql("name")} ILIKE #{Category.normalize_sql("?")}",
"%#{word}%",
)
end
end
categories =
@@ -441,7 +447,7 @@ class CategoriesController < ApplicationController
.joins("LEFT JOIN topics t on t.id = categories.topic_id")
.select("categories.*, t.slug topic_slug")
.order(
"starts_with(lower(categories.name), #{ActiveRecord::Base.connection.quote(term)}) DESC",
"starts_with(#{Category.normalize_sql("categories.name")}, #{Category.normalize_sql(ActiveRecord::Base.connection.quote(term))}) DESC",
"categories.parent_category_id IS NULL DESC",
"categories.id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
"categories.parent_category_id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
+4
View File
@@ -232,6 +232,10 @@ class Category < ActiveRecord::Base
enum :style_type, { square: 0, icon: 1, emoji: 2 }
def self.normalize_sql(expr)
"lower(unaccent(#{expr}))"
end
def self.preload_user_fields!(guardian, categories)
category_ids = categories.map(&:id)
+7 -3
View File
@@ -79,12 +79,16 @@ class CategoryHashtagDataSource
.includes(:parent_category)
if condition == HashtagAutocompleteService.search_conditions[:starts_with]
base_search = base_search.where("starts_with(LOWER(slug), LOWER(:term))", term: term)
base_search =
base_search.where(
"starts_with(#{Category.normalize_sql("slug")}, #{Category.normalize_sql(":term")})",
term:,
)
elsif condition == HashtagAutocompleteService.search_conditions[:contains]
base_search =
base_search.where(
"position(LOWER(:term) IN LOWER(name)) <> 0 OR position(LOWER(:term) IN LOWER(slug)) <> 0",
term: term,
"position(#{Category.normalize_sql(":term")} IN #{Category.normalize_sql("name")}) <> 0 OR position(#{Category.normalize_sql(":term")} IN #{Category.normalize_sql("slug")}) <> 0",
term:,
)
else
raise Discourse::InvalidParameters.new("Unknown search condition: #{condition}")
+3 -3
View File
@@ -34,11 +34,11 @@ class CategoryHierarchicalSearch
if query_params[:term].present?
<<~SQL
(
starts_with(LOWER(name), LOWER(:term))
starts_with(#{Category.normalize_sql("name")}, #{Category.normalize_sql(":term")})
OR COALESCE(
(
SELECT BOOL_AND(position(pattern IN LOWER(allowed_categories.name)) <> 0)
FROM unnest(regexp_split_to_array(LOWER(:term), '\\s+')) AS pattern
SELECT BOOL_AND(position(pattern IN #{Category.normalize_sql("allowed_categories.name")}) <> 0)
FROM unnest(regexp_split_to_array(#{Category.normalize_sql(":term")}, '\\s+')) AS pattern
),
true
)
@@ -1,4 +1,5 @@
import { ajax } from "discourse/lib/ajax";
import { removeAccents } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
const MAX_RESULTS = 20;
@@ -306,16 +307,17 @@ class FilterTypeValueSuggester {
async getCategorySuggestions() {
const categories = this.context.site?.categories || [];
const searchLower = this.searchTerm.toLowerCase();
const normalize = (str) => removeAccents(str.toLowerCase());
const searchNormalized = normalize(this.searchTerm);
return categories
.filter((cat) => {
const name = cat.name.toLowerCase();
const slug = cat.slug.toLowerCase();
const name = normalize(cat.name);
const slug = normalize(cat.slug);
return (
!searchLower ||
name.includes(searchLower) ||
slug.includes(searchLower)
!searchNormalized ||
name.includes(searchNormalized) ||
slug.includes(searchNormalized)
);
})
.slice(0, 10)
+5 -1
View File
@@ -338,12 +338,16 @@ export function clipboardHelpers(e, opts) {
return { clipboard, types, canUpload, canPasteHtml };
}
export function removeAccents(string) {
return string.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
// Replace any accented characters with their ASCII equivalent
// Return the string if it only contains ASCII printable characters,
// otherwise use the fallback
export function toAsciiPrintable(string, fallback) {
if (typeof string.normalize === "function") {
string = string.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
string = removeAccents(string);
}
return /^[\040-\176]*$/.test(string) ? string : fallback;
+8 -6
View File
@@ -18,6 +18,7 @@ import { MultiCache } from "discourse/lib/multi-cache";
import { NotificationLevels } from "discourse/lib/notification-levels";
import { trackedArray } from "discourse/lib/tracked-tools";
import { applyValueTransformer } from "discourse/lib/transformer";
import { removeAccents } from "discourse/lib/utilities";
import PermissionType from "discourse/models/permission-type";
import RestModel from "discourse/models/rest";
import Site from "discourse/models/site";
@@ -353,8 +354,10 @@ export default class Category extends RestModel {
const emptyTerm = term === "";
let slugTerm = term;
const normalize = (str) => removeAccents(str.toLowerCase());
if (!emptyTerm) {
term = term.toLowerCase();
term = normalize(term);
slugTerm = term;
term = term.replace(/-/g, " ");
}
@@ -380,8 +383,8 @@ export default class Category extends RestModel {
if (
((emptyTerm && !category.get("parent_category_id")) ||
(!emptyTerm &&
(category.get("name").toLowerCase().startsWith(term) ||
category.get("slug").toLowerCase().startsWith(slugTerm)))) &&
(normalize(category.get("name")).startsWith(term) ||
normalize(category.get("slug")).startsWith(slugTerm)))) &&
validCategoryParent(category)
) {
data.push(category);
@@ -393,9 +396,8 @@ export default class Category extends RestModel {
const category = categories[i];
if (
((!emptyTerm &&
category.get("name").toLowerCase().indexOf(term) > 0) ||
category.get("slug").toLowerCase().indexOf(slugTerm) > 0) &&
((!emptyTerm && normalize(category.get("name")).indexOf(term) > 0) ||
normalize(category.get("slug")).indexOf(slugTerm) > 0) &&
validCategoryParent(category)
) {
if (!data.includes(category)) {
@@ -414,6 +414,38 @@ module("Unit | Model | category", function (hooks) {
);
});
test("search with accented category name", function (assert) {
const store = getOwner(this).lookup("service:store");
const category1 = store.createRecord("category", {
id: 1,
name: "Éditions",
slug: "publications",
});
const category2 = store.createRecord("category", {
id: 2,
name: "Cinéma",
slug: "films",
});
sinon.stub(Category, "listByActivity").returns([category1, category2]);
assert.deepEqual(
Category.search("editions"),
[category1],
"finds accented category with unaccented search term"
);
assert.deepEqual(
Category.search("cinema"),
[category2],
"finds accented category by unaccented name"
);
assert.deepEqual(
Category.search("Édit"),
[category1],
"finds accented category with accented search term"
);
});
test("search with category slug", function (assert) {
const store = getOwner(this).lookup("service:store");
const category1 = store.createRecord("category", {
+14 -5
View File
@@ -608,8 +608,8 @@ class Search
categories c
JOIN
unnest(ARRAY[:matches]) AS term ON
c.slug ILIKE term OR
c.name ILIKE term OR
#{Category.normalize_sql("c.slug")} ILIKE #{Category.normalize_sql("term")} OR
#{Category.normalize_sql("c.name")} ILIKE #{Category.normalize_sql("term")} OR
(term ~ '^[0-9]{1,10}$' AND c.id = term::int)
SQL
@@ -645,15 +645,24 @@ class Search
category_id =
if subcategory_slug
Category
.where("lower(slug) = ?", subcategory_slug.downcase)
.where(
"#{Category.normalize_sql("slug")} = #{Category.normalize_sql("?")}",
subcategory_slug,
)
.where(
parent_category_id:
Category.where("lower(slug) = ?", category_slug.downcase).select(:id),
Category.where(
"#{Category.normalize_sql("slug")} = #{Category.normalize_sql("?")}",
category_slug,
).select(:id),
)
.pick(:id)
else
Category
.where("lower(slug) = ?", category_slug.downcase)
.where(
"#{Category.normalize_sql("slug")} = #{Category.normalize_sql("?")}",
category_slug,
)
.order("case when parent_category_id is null then 0 else 1 end")
.pick(:id)
end
+10
View File
@@ -1655,6 +1655,16 @@ RSpec.describe Search do
expect(search.posts).to eq([ignored_category.topic.first_post])
end
it "matches categories with accented names using category: filter" do
accented_category =
Fabricate(:category_with_definition, name: "Éditions", slug: "publications")
topic_in_accented = Fabricate(:topic, category: accented_category)
post_in_accented = Fabricate(:post, topic: topic_in_accented, raw: "snow monkey")
search = Search.execute("monkey category:Editions")
expect(search.posts.map(&:id)).to include(post_in_accented.id)
end
describe "with child categories" do
let!(:child_of_ignored_category) do
Fabricate(
@@ -1593,6 +1593,22 @@ RSpec.describe CategoriesController do
"Notfoo",
)
end
it "matches categories with accented names using unaccented search term" do
accented_category = Fabricate(:category, name: "Éditions")
post "/categories/search.json", params: { term: "editions" }
expect(response.parsed_body["categories"].map { |c| c["name"] }).to include("Éditions")
end
it "matches categories with unaccented names using accented search term" do
Fabricate(:category, name: "Editions")
post "/categories/search.json", params: { term: "Éditions" }
expect(response.parsed_body["categories"].map { |c| c["name"] }).to include("Editions")
end
end
context "with parent_category_id" do
@@ -138,6 +138,21 @@ RSpec.describe CategoryHashtagDataSource do
expect(result.slug).to eq("books")
end
it "finds categories with accented names using unaccented search term" do
Fabricate(:category, name: "Cinéma", slug: "films", topic_count: 10)
result = described_class.search(guardian, "cinema", 5)
expect(result.map(&:text)).to include("Cinéma")
end
it "finds categories with accented slugs using unaccented search term" do
SiteSetting.slug_generation_method = "none"
Fabricate(:category, name: "Cinéma", slug: "cinéma", topic_count: 10)
result = described_class.search(guardian, "cinema", 5)
expect(result.map(&:text)).to include("Cinéma")
end
it "does not find categories the user cannot access" do
expect(described_class.search(guardian, "secret", 5).first).to eq(nil)
group.add(user)
@@ -87,6 +87,15 @@ RSpec.describe CategoryHierarchicalSearch do
)
end
it "matches categories with accented names using unaccented search term" do
Fabricate(:category, name: "Éditions spéciales")
context = described_class.call(guardian: Guardian.new, params: { term: "editions speciales" })
expect(context).to be_success
expect(context.categories.map(&:name)).to include("Éditions spéciales")
end
it "returns categories with their ancestors that match the terms param in a hierarchical order" do
context = described_class.call(guardian: Guardian.new, params: { term: "match" })