Совместное использование annotate и distinct(field) в Django
У меня есть куча отзывов в моем приложении. Пользователи могут "любить" отзывы.
Я пытаюсь получить наиболее понравившиеся отзывы. Однако в приложении есть несколько популярных пользователей, и все их отзывы имеют наибольшее количество лайков. Я хочу выбрать только один отзыв (в идеале самый понравившийся) для каждого пользователя.
Вот мои объекты,
class Review(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='review_user', db_index=True)
review_text = models.TextField(max_length=5000)
rating = models.SmallIntegerField(
validators=[
MaxValueValidator(10),
MinValueValidator(1),
],
)
date_added = models.DateTimeField(db_index=True)
review_id = models.AutoField(primary_key=True, db_index=True)
class LikeReview(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='likereview_user', db_index=True)
review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name='likereview_review', db_index=True)
date_added = models.DateTimeField()
class Meta:
unique_together = [['user', 'review']]
А вот что у меня сейчас есть, чтобы получить больше всего понравившихся отзывов:
reviews = Review.objects.filter().annotate(
num_likes=Count('likereview_review')
).order_by('-num_likes').distinct()
Как вы видите, отзывы, которые я получу, будут отсортированы по наибольшему количеству "лайков", но возможно, что все отзывы с наибольшим количеством "лайков" принадлежат одному и тому же пользователю. Я хочу добавить distinct('user') сюда, но получаю annotate() + distinct(fields) is not implemented.
Как я могу этого добиться?
Один из способов сделать это заключается в следующем:
- Получите список кортежей, представляющих user.id и review.id, упорядоченных по пользователю и количеству лайков по возрастанию .
- Преобразуйте список в dict для удаления дубликатов. Более поздние элементы заменяют более ранние, поэтому важно упорядочивание на шаге 1 .
- Создайте список review.ids из значений в дикте .
- Получите набор запросов, используя список review.ids, упорядоченный по количеству лайков ОТСУТСТВИЕ .
from django.db.models import Count
user_review_list = Review.objects\
.annotate(num_likes=Count('likereview_review'))\
.order_by('user', 'num_likes')\
.values_list('user', 'pk')
user_review_dict = dict(user_review_list)
review_pk_list = list(user_review_dict.values())
reviews = Review.objects\
.annotate(num_likes=Count('likereview_review'))\
.filter(pk__in=review_pk_list)\
.order_by('-num_likes')
Это будет немного плохо читаемо из-за ваших связанных имен. Я бы предложил изменить Review.user.related_name на reviews, это сделает это гораздо более понятным, но я подробно описал это во второй части ответа.
При вашей текущей настройке мне удалось сделать это полностью в БД, используя подзапросы:
from django.db.models import Subquery, OuterRef, Count
# No DB Queries
best_reviews_per_user = Review.objects.all()\
.annotate(num_likes=Count('likereview_review'))\
.order_by('-num_likes')\
.filter(user=OuterRef('id'))
# No DB Queries
review_sq = Subquery(best_reviews_per_user.values('review_id')[:1])
# First DB Query
best_review_ids = User.objects.all()\
.annotate(best_review_id=review_sq)\
.values_list('best_review_id', flat=True)
# Second DB Query
best_reviews = Review.objects.all()\
.annotate(num_likes=Count('likereview_review'))\
.order_by('-num_likes')\
.filter(review_id__in=best_review_ids)\
.exclude(num_likes=0) # I assume this is the case
# Print it
for review in best_reviews:
print(review, review.num_likes, review.user)
# Test it
assert len({review.user for review in best_reviews}) == len(best_reviews)
assert sorted([r.num_likes for r in best_reviews], reverse=True) == [r.num_likes for r in best_reviews]
assert all([r.num_likes for r in best_reviews])
Попробуем с этой полностью эквивалентной модельной структурой:
from django.db import models
from django.utils import timezone
class TimestampedModel(models.Model):
"""This makes your life much easier and is pretty DRY"""
created = models.DateTimeField(default=timezone.now)
class Meta:
abstract = True
class Review(TimestampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reviews', db_index=True)
text = models.TextField(max_length=5000)
rating = models.SmallIntegerField()
likes = models.ManyToManyField(User, through='ReviewLike')
class ReviewLike(TimestampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True)
review = models.ForeignKey(Review, on_delete=models.CASCADE, db_index=True)
Похожие - это четкие m2m отношения между отзывами и пользователями, с дополнительным столбцом timestamp - это использование модели Through. Документы здесь.
Теперь имхо все гораздо легче читать.
from django.db.models import OuterRef, Count, Subquery
# No DB Queries
best_reviews = Review.objects.all()\
.annotate(like_count=Count('likes'))\
.exclude(like_count=0)\
.order_by('-like_count')\
# No DB Queries
sq = Subquery(best_reviews.filter(user=OuterRef('id')).values('id')[:1])
# First DB Query
user_distinct_best_review_ids = User.objects.all()\
.annotate(best_review=sq)\
.values_list('best_review', flat=True)
# Second DB Query
best_reviews = best_reviews.filter(id__in=user_distinct_best_review_ids).all()