From 369ff757b00f99bcdd5f535618235ad6f898b2d6 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 22 Mar 2025 13:37:43 +0300 Subject: [PATCH] [0.4.16] - 2025-03-22 - Added hierarchical comments pagination: - Created new GraphQL query `load_comments_branch` for efficient loading of hierarchical comments - Ability to load root comments with their first N replies - Added pagination for both root and child comments - Using existing `commented` field in `Stat` type to display number of replies - Added special `first_replies` field to store first replies to a comment - Optimized SQL queries for efficient loading of comment hierarchies - Implemented flexible comment sorting system (by time, rating) --- CHANGELOG.md | 10 ++ docs/comments-pagination.md | 165 +++++++++++++++++++++++++++++++ docs/features.md | 13 ++- resolvers/__init__.py | 2 + resolvers/reaction.py | 191 +++++++++++++++++++++++++++++++++++- schema/query.graphql | 3 + 6 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 docs/comments-pagination.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 987e2303..b9b5f937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +#### [0.4.16] - 2025-03-22 +- Added hierarchical comments pagination: + - Created new GraphQL query `load_comments_branch` for efficient loading of hierarchical comments + - Ability to load root comments with their first N replies + - Added pagination for both root and child comments + - Using existing `commented` field in `Stat` type to display number of replies + - Added special `first_replies` field to store first replies to a comment + - Optimized SQL queries for efficient loading of comment hierarchies + - Implemented flexible comment sorting system (by time, rating) + #### [0.4.15] - 2025-03-22 - Upgraded caching system described `docs/caching.md` - Module `cache/memorycache.py` removed diff --git a/docs/comments-pagination.md b/docs/comments-pagination.md new file mode 100644 index 00000000..3b34a0de --- /dev/null +++ b/docs/comments-pagination.md @@ -0,0 +1,165 @@ +# Пагинация комментариев + +## Обзор + +Реализована система пагинации комментариев по веткам, которая позволяет эффективно загружать и отображать вложенные ветки обсуждений. Основные преимущества: + +1. Загрузка только необходимых комментариев, а не всего дерева +2. Снижение нагрузки на сервер и клиент +3. Возможность эффективной навигации по большим обсуждениям +4. Предзагрузка первых N ответов для улучшения UX + +## API для иерархической загрузки комментариев + +### GraphQL запрос `load_comments_branch` + +```graphql +query LoadCommentsBranch( + $shout: Int!, + $parentId: Int, + $limit: Int, + $offset: Int, + $sort: ReactionSort, + $childrenLimit: Int, + $childrenOffset: Int +) { + load_comments_branch( + shout: $shout, + parent_id: $parentId, + limit: $limit, + offset: $offset, + sort: $sort, + children_limit: $childrenLimit, + children_offset: $childrenOffset + ) { + id + body + created_at + created_by { + id + name + slug + pic + } + kind + reply_to + stat { + rating + commented + } + first_replies { + id + body + created_at + created_by { + id + name + slug + pic + } + kind + reply_to + stat { + rating + commented + } + } + } +} +``` + +### Параметры запроса + +| Параметр | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| shout | Int! | - | ID статьи, к которой относятся комментарии | +| parent_id | Int | null | ID родительского комментария. Если null, загружаются корневые комментарии | +| limit | Int | 10 | Максимальное количество комментариев для загрузки | +| offset | Int | 0 | Смещение для пагинации | +| sort | ReactionSort | newest | Порядок сортировки: newest, oldest, like | +| children_limit | Int | 3 | Максимальное количество дочерних комментариев для каждого родительского | +| children_offset | Int | 0 | Смещение для пагинации дочерних комментариев | + +### Поля в ответе + +Каждый комментарий содержит следующие основные поля: + +- `id`: ID комментария +- `body`: Текст комментария +- `created_at`: Время создания +- `created_by`: Информация об авторе +- `kind`: Тип реакции (COMMENT) +- `reply_to`: ID родительского комментария (null для корневых) +- `first_replies`: Первые N дочерних комментариев +- `stat`: Статистика комментария, включающая: + - `commented`: Количество ответов на комментарий + - `rating`: Рейтинг комментария + +## Примеры использования + +### Загрузка корневых комментариев с первыми ответами + +```javascript +const { data } = await client.query({ + query: LOAD_COMMENTS_BRANCH, + variables: { + shout: 222, + limit: 10, + offset: 0, + sort: "newest", + childrenLimit: 3 + } +}); +``` + +### Загрузка ответов на конкретный комментарий + +```javascript +const { data } = await client.query({ + query: LOAD_COMMENTS_BRANCH, + variables: { + shout: 222, + parentId: 123, // ID комментария, для которого загружаем ответы + limit: 10, + offset: 0, + sort: "oldest" // Сортируем ответы от старых к новым + } +}); +``` + +### Пагинация дочерних комментариев + +Для загрузки дополнительных ответов на комментарий: + +```javascript +const { data } = await client.query({ + query: LOAD_COMMENTS_BRANCH, + variables: { + shout: 222, + parentId: 123, + limit: 10, + offset: 0, + childrenLimit: 5, + childrenOffset: 3 // Пропускаем первые 3 комментария (уже загруженные) + } +}); +``` + +## Рекомендации по клиентской реализации + +1. Для эффективной работы со сложными ветками обсуждений рекомендуется: + + - Сначала загружать только корневые комментарии с первыми N ответами + - При наличии дополнительных ответов (когда `stat.commented > first_replies.length`) + добавить кнопку "Показать все ответы" + - При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId` + +2. Для сортировки: + - По умолчанию использовать `newest` для отображения свежих обсуждений + - Предусмотреть переключатель сортировки для всего дерева комментариев + - При изменении сортировки перезагружать данные с новым параметром `sort` + +3. Для улучшения производительности: + - Кешировать результаты запросов на клиенте + - Использовать оптимистичные обновления при добавлении/редактировании комментариев + - При необходимости загружать комментарии порциями (ленивая загрузка) \ No newline at end of file diff --git a/docs/features.md b/docs/features.md index e0ed3526..37ff05fc 100644 --- a/docs/features.md +++ b/docs/features.md @@ -34,4 +34,15 @@ - Поддерживаемые методы: GET, POST, OPTIONS - Настроена поддержка credentials - Разрешенные заголовки: Authorization, Content-Type, X-Requested-With, DNT, Cache-Control -- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд) \ No newline at end of file +- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд) + +## Пагинация комментариев по веткам + +- Эффективная загрузка комментариев с учетом их иерархической структуры +- Отдельный запрос `load_comments_branch` для оптимизированной загрузки ветки комментариев +- Возможность загрузки корневых комментариев статьи с первыми ответами на них +- Гибкая пагинация как для корневых, так и для дочерних комментариев +- Использование поля `stat.commented` для отображения количества ответов на комментарий +- Добавление специального поля `first_replies` для хранения первых ответов на комментарий +- Поддержка различных методов сортировки (новые, старые, популярные) +- Оптимизированные SQL запросы для минимизации нагрузки на базу данных \ No newline at end of file diff --git a/resolvers/__init__.py b/resolvers/__init__.py index 4d2f8d69..699bc4c4 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -37,6 +37,7 @@ from resolvers.reaction import ( create_reaction, delete_reaction, load_comment_ratings, + load_comments_branch, load_reactions_by, load_shout_comments, load_shout_ratings, @@ -107,6 +108,7 @@ __all__ = [ "load_shout_comments", "load_shout_ratings", "load_comment_ratings", + "load_comments_branch", # notifier "load_notifications", "notifications_seen_thread", diff --git a/resolvers/reaction.py b/resolvers/reaction.py index 89c4f9ac..35d2d536 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -612,24 +612,22 @@ async def load_shout_comments(_, info, shout: int, limit=50, offset=0): @query.field("load_comment_ratings") async def load_comment_ratings(_, info, comment: int, limit=50, offset=0): """ - Load ratings for a specified comment with pagination and statistics. + Load ratings for a specified comment with pagination. :param info: GraphQL context info. :param comment: Comment ID. :param limit: Number of ratings to load. :param offset: Pagination offset. - :return: List of reactions. + :return: List of ratings. """ q = query_reactions() - q = add_reaction_stat_columns(q) - # Filter, group, sort, limit, offset q = q.filter( and_( Reaction.deleted_at.is_(None), Reaction.reply_to == comment, - Reaction.kind == ReactionKind.COMMENT.value, + Reaction.kind.in_(RATING_REACTIONS), ) ) q = q.group_by(Reaction.id, Author.id, Shout.id) @@ -637,3 +635,186 @@ async def load_comment_ratings(_, info, comment: int, limit=50, offset=0): # Retrieve and return reactions return get_reactions_with_stat(q, limit, offset) + + +@query.field("load_comments_branch") +async def load_comments_branch( + _, + _info, + shout: int, + parent_id: int | None = None, + limit=10, + offset=0, + sort="newest", + children_limit=3, + children_offset=0, +): + """ + Загружает иерархические комментарии с возможностью пагинации корневых и дочерних. + + :param info: GraphQL context info. + :param shout: ID статьи. + :param parent_id: ID родительского комментария (None для корневых). + :param limit: Количество комментариев для загрузки. + :param offset: Смещение для пагинации. + :param sort: Порядок сортировки ('newest', 'oldest', 'like'). + :param children_limit: Максимальное количество дочерних комментариев. + :param children_offset: Смещение для дочерних комментариев. + :return: Список комментариев с дочерними. + """ + # Создаем базовый запрос + q = query_reactions() + q = add_reaction_stat_columns(q) + + # Фильтруем по статье и типу (комментарии) + q = q.filter( + and_( + Reaction.deleted_at.is_(None), + Reaction.shout == shout, + Reaction.kind == ReactionKind.COMMENT.value, + ) + ) + + # Фильтруем по родительскому ID + if parent_id is None: + # Загружаем только корневые комментарии + q = q.filter(Reaction.reply_to.is_(None)) + else: + # Загружаем только прямые ответы на указанный комментарий + q = q.filter(Reaction.reply_to == parent_id) + + # Сортировка и группировка + q = q.group_by(Reaction.id, Author.id, Shout.id) + + # Определяем сортировку + order_by_stmt = None + if sort.lower() == "oldest": + order_by_stmt = asc(Reaction.created_at) + elif sort.lower() == "like": + order_by_stmt = desc("rating_stat") + else: # "newest" по умолчанию + order_by_stmt = desc(Reaction.created_at) + + q = q.order_by(order_by_stmt) + + # Выполняем запрос для получения комментариев + comments = get_reactions_with_stat(q, limit, offset) + + # Если комментарии найдены, загружаем дочерние и количество ответов + if comments: + # Загружаем количество ответов для каждого комментария + await load_replies_count(comments) + + # Загружаем дочерние комментарии + await load_first_replies(comments, children_limit, children_offset, sort) + + return comments + + +async def load_replies_count(comments): + """ + Загружает количество ответов для списка комментариев и обновляет поле stat.commented. + + :param comments: Список комментариев, для которых нужно загрузить количество ответов. + """ + if not comments: + return + + comment_ids = [comment["id"] for comment in comments] + + # Запрос для подсчета количества ответов + q = ( + select(Reaction.reply_to.label("parent_id"), func.count().label("count")) + .where( + and_( + Reaction.reply_to.in_(comment_ids), + Reaction.deleted_at.is_(None), + Reaction.kind == ReactionKind.COMMENT.value, + ) + ) + .group_by(Reaction.reply_to) + ) + + # Выполняем запрос + with local_session() as session: + result = session.execute(q).fetchall() + + # Создаем словарь {parent_id: count} + replies_count = {row[0]: row[1] for row in result} + + # Добавляем значения в комментарии + for comment in comments: + if "stat" not in comment: + comment["stat"] = {} + + # Обновляем счетчик комментариев в stat + comment["stat"]["commented"] = replies_count.get(comment["id"], 0) + + +async def load_first_replies(comments, limit, offset, sort="newest"): + """ + Загружает первые N ответов для каждого комментария. + + :param comments: Список комментариев, для которых нужно загрузить ответы. + :param limit: Максимальное количество ответов для каждого комментария. + :param offset: Смещение для пагинации дочерних комментариев. + :param sort: Порядок сортировки ответов. + """ + if not comments or limit <= 0: + return + + # Собираем ID комментариев + comment_ids = [comment["id"] for comment in comments] + + # Базовый запрос для загрузки ответов + q = query_reactions() + q = add_reaction_stat_columns(q) + + # Фильтрация: только ответы на указанные комментарии + q = q.filter( + and_( + Reaction.reply_to.in_(comment_ids), + Reaction.deleted_at.is_(None), + Reaction.kind == ReactionKind.COMMENT.value, + ) + ) + + # Группировка + q = q.group_by(Reaction.id, Author.id, Shout.id) + + # Определяем сортировку + order_by_stmt = None + if sort.lower() == "oldest": + order_by_stmt = asc(Reaction.created_at) + elif sort.lower() == "like": + order_by_stmt = desc("rating_stat") + else: # "newest" по умолчанию + order_by_stmt = desc(Reaction.created_at) + + q = q.order_by(order_by_stmt, Reaction.reply_to) + + # Выполняем запрос + replies = get_reactions_with_stat(q) + + # Группируем ответы по родительским ID + replies_by_parent = {} + for reply in replies: + parent_id = reply.get("reply_to") + if parent_id not in replies_by_parent: + replies_by_parent[parent_id] = [] + replies_by_parent[parent_id].append(reply) + + # Добавляем ответы к соответствующим комментариям с учетом смещения и лимита + for comment in comments: + comment_id = comment["id"] + if comment_id in replies_by_parent: + parent_replies = replies_by_parent[comment_id] + # Применяем смещение и лимит + comment["first_replies"] = parent_replies[offset : offset + limit] + else: + comment["first_replies"] = [] + + # Загружаем количество ответов для дочерних комментариев + all_replies = [reply for replies in replies_by_parent.values() for reply in replies] + if all_replies: + await load_replies_count(all_replies) diff --git a/schema/query.graphql b/schema/query.graphql index f39fba5c..ce839aed 100644 --- a/schema/query.graphql +++ b/schema/query.graphql @@ -26,6 +26,9 @@ type Query { load_shout_ratings(shout: Int!, limit: Int, offset: Int): [Reaction] load_comment_ratings(comment: Int!, limit: Int, offset: Int): [Reaction] + # branched comments pagination + load_comments_branch(shout: Int!, parent_id: Int, limit: Int, offset: Int, sort: ReactionSort, children_limit: Int, children_offset: Int): [Reaction] + # reader get_shout(slug: String, shout_id: Int): Shout load_shouts_by(options: LoadShoutsOptions): [Shout]