- 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)
This commit is contained in:
parent
615f1fe468
commit
369ff757b0
10
CHANGELOG.md
10
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
|
#### [0.4.15] - 2025-03-22
|
||||||
- Upgraded caching system described `docs/caching.md`
|
- Upgraded caching system described `docs/caching.md`
|
||||||
- Module `cache/memorycache.py` removed
|
- Module `cache/memorycache.py` removed
|
||||||
|
|
165
docs/comments-pagination.md
Normal file
165
docs/comments-pagination.md
Normal file
|
@ -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. Для улучшения производительности:
|
||||||
|
- Кешировать результаты запросов на клиенте
|
||||||
|
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
|
||||||
|
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
|
@ -35,3 +35,14 @@
|
||||||
- Настроена поддержка credentials
|
- Настроена поддержка credentials
|
||||||
- Разрешенные заголовки: Authorization, Content-Type, X-Requested-With, DNT, Cache-Control
|
- Разрешенные заголовки: Authorization, Content-Type, X-Requested-With, DNT, Cache-Control
|
||||||
- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд)
|
- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд)
|
||||||
|
|
||||||
|
## Пагинация комментариев по веткам
|
||||||
|
|
||||||
|
- Эффективная загрузка комментариев с учетом их иерархической структуры
|
||||||
|
- Отдельный запрос `load_comments_branch` для оптимизированной загрузки ветки комментариев
|
||||||
|
- Возможность загрузки корневых комментариев статьи с первыми ответами на них
|
||||||
|
- Гибкая пагинация как для корневых, так и для дочерних комментариев
|
||||||
|
- Использование поля `stat.commented` для отображения количества ответов на комментарий
|
||||||
|
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
|
||||||
|
- Поддержка различных методов сортировки (новые, старые, популярные)
|
||||||
|
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
|
|
@ -37,6 +37,7 @@ from resolvers.reaction import (
|
||||||
create_reaction,
|
create_reaction,
|
||||||
delete_reaction,
|
delete_reaction,
|
||||||
load_comment_ratings,
|
load_comment_ratings,
|
||||||
|
load_comments_branch,
|
||||||
load_reactions_by,
|
load_reactions_by,
|
||||||
load_shout_comments,
|
load_shout_comments,
|
||||||
load_shout_ratings,
|
load_shout_ratings,
|
||||||
|
@ -107,6 +108,7 @@ __all__ = [
|
||||||
"load_shout_comments",
|
"load_shout_comments",
|
||||||
"load_shout_ratings",
|
"load_shout_ratings",
|
||||||
"load_comment_ratings",
|
"load_comment_ratings",
|
||||||
|
"load_comments_branch",
|
||||||
# notifier
|
# notifier
|
||||||
"load_notifications",
|
"load_notifications",
|
||||||
"notifications_seen_thread",
|
"notifications_seen_thread",
|
||||||
|
|
|
@ -612,24 +612,22 @@ async def load_shout_comments(_, info, shout: int, limit=50, offset=0):
|
||||||
@query.field("load_comment_ratings")
|
@query.field("load_comment_ratings")
|
||||||
async def load_comment_ratings(_, info, comment: int, limit=50, offset=0):
|
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 info: GraphQL context info.
|
||||||
:param comment: Comment ID.
|
:param comment: Comment ID.
|
||||||
:param limit: Number of ratings to load.
|
:param limit: Number of ratings to load.
|
||||||
:param offset: Pagination offset.
|
:param offset: Pagination offset.
|
||||||
:return: List of reactions.
|
:return: List of ratings.
|
||||||
"""
|
"""
|
||||||
q = query_reactions()
|
q = query_reactions()
|
||||||
|
|
||||||
q = add_reaction_stat_columns(q)
|
|
||||||
|
|
||||||
# Filter, group, sort, limit, offset
|
# Filter, group, sort, limit, offset
|
||||||
q = q.filter(
|
q = q.filter(
|
||||||
and_(
|
and_(
|
||||||
Reaction.deleted_at.is_(None),
|
Reaction.deleted_at.is_(None),
|
||||||
Reaction.reply_to == comment,
|
Reaction.reply_to == comment,
|
||||||
Reaction.kind == ReactionKind.COMMENT.value,
|
Reaction.kind.in_(RATING_REACTIONS),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
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
|
# Retrieve and return reactions
|
||||||
return get_reactions_with_stat(q, limit, offset)
|
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)
|
||||||
|
|
|
@ -26,6 +26,9 @@ type Query {
|
||||||
load_shout_ratings(shout: Int!, limit: Int, offset: Int): [Reaction]
|
load_shout_ratings(shout: Int!, limit: Int, offset: Int): [Reaction]
|
||||||
load_comment_ratings(comment: 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
|
# reader
|
||||||
get_shout(slug: String, shout_id: Int): Shout
|
get_shout(slug: String, shout_id: Int): Shout
|
||||||
load_shouts_by(options: LoadShoutsOptions): [Shout]
|
load_shouts_by(options: LoadShoutsOptions): [Shout]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user