From f644ee1879b7ea7d69027518fe474fa4d56dff1e Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 24 May 2026 11:43:49 -0500 Subject: [PATCH] fix: sort recipe names accent-folded for locale-aware ordering (#6853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- mealie/repos/repository_recipes.py | 16 ++++++++- .../test_recipe_repository.py | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 7605f951c..1b66b8a9a 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -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 diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py index 5502b05f7..733480710 100644 --- a/tests/unit_tests/repository_tests/test_recipe_repository.py +++ b/tests/unit_tests/repository_tests/test_recipe_repository.py @@ -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)