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)