From 599a6c9f5931bae9e0bd05376764d011be2cc59f Mon Sep 17 00:00:00 2001 From: Untone Date: Thu, 26 Jun 2025 17:19:42 +0300 Subject: [PATCH] authors-sort-fix3 --- CHANGELOG.md | 15 +++++ docs/README.md | 1 + pyproject.toml | 6 +- resolvers/author.py | 151 +++++++++++++++++++++++++++++++------------- 4 files changed, 127 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb58b47b..c95c7eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.5.6] - 2025-06-26 + +### Исправления API + +- **Исправлена сортировка авторов**: Решена проблема с неправильной обработкой параметра сортировки в `load_authors_by`: + - **Проблема**: При запросе авторов с параметром сортировки `order="shouts"` всегда применялась сортировка по `followers` + - **Исправления**: + - Создан специальный тип `AuthorsBy` на основе схемы GraphQL для строгой типизации параметра сортировки + - Улучшена обработка параметра `by` в функции `load_authors_by` для поддержки всех полей из схемы GraphQL + - Исправлена логика определения поля сортировки `stats_sort_field` для корректного применения сортировки + - Добавлен флаг `default_sort_applied` для предотвращения конфликтов между разными типами сортировки + - Улучшено кеширование с учетом параметра сортировки в ключе кеша + - Добавлено подробное логирование для отладки SQL запросов и результатов сортировки + - **Результат**: API корректно возвращает авторов, отсортированных по указанному параметру, включая сортировку по количеству публикаций (`shouts`) и подписчиков (`followers`) + ## [0.5.5] - 2025-06-19 ### Улучшения документации diff --git a/docs/README.md b/docs/README.md index 3429ecec..11fd20ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,7 @@ python dev.py - **Автоматическая очистка** истекших токенов - **Connection pooling** и keepalive - **Type-safe codebase** (mypy clean) +- **Оптимизированная сортировка авторов** с кешированием по параметрам ## 🔧 Конфигурация diff --git a/pyproject.toml b/pyproject.toml index 6a6121be..322ea627 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,14 +98,16 @@ ignore = [ "FBT002", # boolean default arguments - иногда удобно для API совместимости "PERF203", # try-except in loop - иногда нужно для обработки отдельных элементов # Игнорируем некоторые строгие правила для удобства разработки + "ANN003", # Missing type annotation for `*args` - иногда нужно "ANN401", # Dynamically typed expressions (Any) - иногда нужно "S101", # assert statements - нужно в тестах "T201", # print statements - нужно для отладки "TRY003", # Avoid specifying long messages outside the exception class - иногда допустимо "PLR2004", # Magic values - иногда допустимо "RUF001", # ambiguous unicode characters - для кириллицы - "RUF002", # ambiguous unicode characters in docstrings - для кириллицы - "RUF003", # ambiguous unicode characters in comments - для кириллицы + "RUF002", # + "RUF003", # + "RUF006", # "TD002", # TODO без автора - не критично "TD003", # TODO без ссылки на issue - не критично ] diff --git a/resolvers/author.py b/resolvers/author.py index e188da8b..582f91c4 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -1,6 +1,6 @@ import asyncio import time -from typing import Any, Optional +from typing import Any, Optional, TypedDict from graphql import GraphQLResolveInfo from sqlalchemy import select, text @@ -26,6 +26,32 @@ from utils.logger import root_logger as logger DEFAULT_COMMUNITIES = [1] +# Определение типа AuthorsBy на основе схемы GraphQL +class AuthorsBy(TypedDict, total=False): + """ + Тип для параметра сортировки авторов, соответствующий схеме GraphQL. + + Поля: + last_seen: Временная метка последнего посещения + created_at: Временная метка создания + slug: Уникальный идентификатор автора + name: Имя автора + topic: Тема, связанная с автором + order: Поле для сортировки (shouts, followers, rating, comments, name) + after: Временная метка для фильтрации "после" + stat: Поле статистики + """ + + last_seen: Optional[int] + created_at: Optional[int] + slug: Optional[str] + name: Optional[str] + topic: Optional[str] + order: Optional[str] + after: Optional[int] + stat: Optional[str] + + # Вспомогательная функция для получения всех авторов без статистики async def get_all_authors(current_user_id: Optional[int] = None) -> list[Any]: """ @@ -62,7 +88,7 @@ async def get_all_authors(current_user_id: Optional[int] = None) -> list[Any]: # Вспомогательная функция для получения авторов со статистикой с пагинацией async def get_authors_with_stats( - limit: int = 10, offset: int = 0, by: Optional[str] = None, current_user_id: Optional[int] = None + limit: int = 10, offset: int = 0, by: Optional[AuthorsBy] = None, current_user_id: Optional[int] = None ): """ Получает авторов со статистикой с пагинацией. @@ -70,13 +96,14 @@ async def get_authors_with_stats( Args: limit: Максимальное количество возвращаемых авторов offset: Смещение для пагинации - by: Опциональный параметр сортировки (new/active) + by: Опциональный параметр сортировки (AuthorsBy) current_user_id: ID текущего пользователя Returns: list: Список авторов с их статистикой """ # Формируем ключ кеша с помощью универсальной функции - cache_key = f"authors:stats:limit={limit}:offset={offset}" + order_value = by.get("order", "default") if by else "default" + cache_key = f"authors:stats:limit={limit}:offset={offset}:order={order_value}" # Функция для получения авторов из БД async def fetch_authors_with_stats() -> list[Any]: @@ -96,54 +123,56 @@ async def get_authors_with_stats( # Базовый запрос для получения авторов base_query = select(Author).where(Author.deleted_at.is_(None)) - # Применяем сортировку - # vars for statistics sorting stats_sort_field = None + default_sort_applied = False if by: - if isinstance(by, dict): - logger.debug(f"Processing dict-based sorting: {by}") - # Обработка словаря параметров сортировки - - # Checking for order field in the dictionary - if "order" in by: - order_value = by["order"] - logger.debug(f"Found order field with value: {order_value}") - if order_value in ["shouts", "followers", "rating", "comments"]: - stats_sort_field = order_value - logger.debug(f"Applying statistics-based sorting by: {stats_sort_field}") - elif order_value == "name": - # Sorting by name in ascending order - base_query = base_query.order_by(asc(Author.name)) - logger.debug("Applying alphabetical sorting by name") - else: - # If order is not a stats field, treat it as a regular field - column = getattr(Author, order_value, None) - if column: - base_query = base_query.order_by(sql_desc(column)) + if "order" in by: + order_value = by["order"] + logger.debug(f"Found order field with value: {order_value}") + if order_value in ["shouts", "followers", "rating", "comments"]: + stats_sort_field = order_value + logger.debug(f"Applying statistics-based sorting by: {stats_sort_field}") + # Не применяем другую сортировку, так как будем использовать stats_sort_field + default_sort_applied = True + elif order_value == "name": + # Sorting by name in ascending order + base_query = base_query.order_by(asc(Author.name)) + logger.debug("Applying alphabetical sorting by name") + default_sort_applied = True else: - # Regular sorting by fields - for field, direction in by.items(): - column = getattr(Author, field, None) - if column: - if direction.lower() == "desc": - base_query = base_query.order_by(sql_desc(column)) - else: - base_query = base_query.order_by(column) - elif by == "new": - base_query = base_query.order_by(sql_desc(Author.created_at)) - elif by == "active": - base_query = base_query.order_by(sql_desc(Author.last_seen)) + # If order is not a stats field, treat it as a regular field + column = getattr(Author, order_value, None) + if column: + base_query = base_query.order_by(sql_desc(column)) + logger.debug(f"Applying sorting by column: {order_value}") + default_sort_applied = True + else: + logger.warning(f"Unknown order field: {order_value}") else: - # По умолчанию сортируем по времени создания - base_query = base_query.order_by(sql_desc(Author.created_at)) - else: + # Regular sorting by fields + for field, direction in by.items(): + column = getattr(Author, field, None) + if column: + if direction.lower() == "desc": + base_query = base_query.order_by(sql_desc(column)) + else: + base_query = base_query.order_by(column) + logger.debug(f"Applying sorting by field: {field}, direction: {direction}") + default_sort_applied = True + else: + logger.warning(f"Unknown field: {field}") + + # Если сортировка еще не применена, используем сортировку по умолчанию + if not default_sort_applied and not stats_sort_field: base_query = base_query.order_by(sql_desc(Author.created_at)) + logger.debug("Applying default sorting by created_at (no by parameter)") # If sorting by statistics, modify the query if stats_sort_field == "shouts": # Sorting by the number of shouts + logger.debug("Building subquery for shouts sorting") subquery = ( select(ShoutAuthor.author, func.count(func.distinct(Shout.id)).label("shouts_count")) .select_from(ShoutAuthor) @@ -153,11 +182,22 @@ async def get_authors_with_stats( .subquery() ) + # Сбрасываем предыдущую сортировку и применяем новую base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by( sql_desc(func.coalesce(subquery.c.shouts_count, 0)) ) + logger.debug("Applied sorting by shouts count") + + # Логирование для отладки сортировки + try: + # Получаем SQL запрос для проверки + sql_query = str(base_query.compile(compile_kwargs={"literal_binds": True})) + logger.debug(f"Generated SQL query for shouts sorting: {sql_query}") + except Exception as e: + logger.error(f"Error generating SQL query: {e}") elif stats_sort_field == "followers": # Sorting by the number of followers + logger.debug("Building subquery for followers sorting") subquery = ( select( AuthorFollower.author, @@ -168,9 +208,19 @@ async def get_authors_with_stats( .subquery() ) + # Сбрасываем предыдущую сортировку и применяем новую base_query = base_query.outerjoin(subquery, Author.id == subquery.c.author).order_by( sql_desc(func.coalesce(subquery.c.followers_count, 0)) ) + logger.debug("Applied sorting by followers count") + + # Логирование для отладки сортировки + try: + # Получаем SQL запрос для проверки + sql_query = str(base_query.compile(compile_kwargs={"literal_binds": True})) + logger.debug(f"Generated SQL query for followers sorting: {sql_query}") + except Exception as e: + logger.error(f"Error generating SQL query: {e}") # Применяем лимит и смещение base_query = base_query.limit(limit).offset(offset) @@ -182,6 +232,10 @@ async def get_authors_with_stats( if not author_ids: return [] + # Логирование результатов для отладки сортировки + if stats_sort_field: + logger.debug(f"Query returned {len(authors)} authors with sorting by {stats_sort_field}") + # Оптимизированный запрос для получения статистики по публикациям для авторов placeholders = ", ".join([f":id{i}" for i in range(len(author_ids))]) shouts_stats_query = f""" @@ -292,7 +346,7 @@ async def update_author(_: None, info: GraphQLResolveInfo, profile: dict[str, An if isinstance(author_with_stat, Author): # Кэшируем полную версию для админов author_dict = author_with_stat.dict(is_admin) - asyncio.create_task(cache_author(author_dict)) + _t = asyncio.create_task(cache_author(author_dict)) # Возвращаем обычную полную версию, т.к. это владелец return CommonResult(error=None, author=author) @@ -354,7 +408,7 @@ async def get_author( if isinstance(author_with_stat, Author): # Кэшируем полные данные для админов original_dict = author_with_stat.dict(True) - asyncio.create_task(cache_author(original_dict)) + _t = asyncio.create_task(cache_author(original_dict)) # Возвращаем отфильтрованную версию author_dict = author_with_stat.dict(is_admin) @@ -371,13 +425,22 @@ async def get_author( @query.field("load_authors_by") -async def load_authors_by(_: None, info: GraphQLResolveInfo, by: str, limit: int = 10, offset: int = 0) -> list[Any]: +async def load_authors_by( + _: None, info: GraphQLResolveInfo, by: AuthorsBy, limit: int = 10, offset: int = 0 +) -> list[Any]: """Load authors by different criteria""" try: # Получаем ID текущего пользователя и флаг админа из контекста viewer_id = info.context.get("author", {}).get("id") info.context.get("is_admin", False) + # Логирование для отладки + logger.debug(f"load_authors_by called with by={by}, limit={limit}, offset={offset}") + + # Проверяем наличие параметра order в словаре + if "order" in by: + logger.debug(f"Sorting by order={by['order']}") + # Используем оптимизированную функцию для получения авторов return await get_authors_with_stats(limit, offset, by, viewer_id) except Exception as exc: