fix: sort recipe names accent-folded for locale-aware ordering (#6853)

Recipe name sorting applied lower() to the raw name, so accented and
umlaut characters sorted by raw code point and landed after "Z"
(e.g. "Über" after "Zebra"). Sort by the existing unidecode-normalized
name column instead, so accented characters sort next to their base
letter. Works identically on SQLite and Postgres since the sort key is
a pre-computed ASCII column rather than a DB collation.
This commit is contained in:
Hayden
2026-05-24 11:43:49 -05:00
parent 8eb00c3dc0
commit f644ee1879
2 changed files with 49 additions and 1 deletions

View File

@@ -26,7 +26,7 @@ from mealie.schema.recipe.recipe import RecipePagination, RecipeSummary, create_
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationQuery
from mealie.services.query_filter.builder import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase
@@ -92,6 +92,20 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
)
return sa.cast(effective_rating, sa.Float)
def add_order_attr_to_query(
self,
query: sa.Select,
order_attr: orm.InstrumentedAttribute,
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> sa.Select:
# Sort recipe names by their normalized (accent-folded, lowercased) form so that
# accented and umlaut characters sort next to their base letter (e.g. "Über" near
# "U") instead of after "Z" by raw code point. See GH #6853.
if order_attr is RecipeModel.name:
order_attr = RecipeModel.name_normalized
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
def create(self, document: Recipe) -> Recipe: # type: ignore
max_retries = 10
original_name: str = document.name # type: ignore

View File

@@ -647,6 +647,40 @@ def test_order_by_last_made(unique_user: TestUser, h2_user: TestUser):
assert [item.id for item in h2_query.items] == [recipe_2.id, recipe_1.id]
def test_order_by_name_is_accent_folded(unique_user: TestUser):
# Names chosen so that raw code-point sorting (the previous behavior) would place the
# umlaut/accented entries after "Zebra". Accent-folded sorting must instead place them
# next to their base letter: "ärtsoppa" near "a", "Über" near "u". See GH #6853.
names = ["Zebra", "ärtsoppa", "Apple", "Über"]
recipes = [
unique_user.repos.recipes.create(Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=name))
for name in names
]
recipe_ids = ", ".join(str(recipe.id) for recipe in recipes)
ascending = unique_user.repos.recipes.page_all(
PaginationQuery(
page=1,
per_page=-1,
order_by="name",
order_direction=OrderDirection.asc,
query_filter=f"id IN [{recipe_ids}]",
)
)
assert [item.name for item in ascending.items] == ["Apple", "ärtsoppa", "Über", "Zebra"]
descending = unique_user.repos.recipes.page_all(
PaginationQuery(
page=1,
per_page=-1,
order_by="name",
order_direction=OrderDirection.desc,
query_filter=f"id IN [{recipe_ids}]",
)
)
assert [item.name for item in descending.items] == ["Zebra", "Über", "ärtsoppa", "Apple"]
def test_coalesce_last_made(unique_user: TestUser):
dt = datetime.now(UTC)