فصل ۳: QuerySet حرفه‌ای — سریع، درست و بدون N+1

Q و F، annotate و aggregate، values و values_list

منطق را به پایگاه داده بسپارید

یک خطای رایج این است که همه‌ی ردیف‌ها را به پایتون بیاوریم و آن‌جا جمع، شمارش یا مقایسه کنیم. پایگاه داده برای همین کارها ساخته شده و ده‌ها برابر سریع‌تر است. ابزارهای اصلی ORM برای این کار Q، F، annotate و aggregate هستند.

Q: شرط‌های «یا» و «نقیض»

from django.db.models import Q

# سفارش‌های پرداخت‌شده یا ارسال‌شده که مشتری‌شان تهرانی نیست
Order.objects.filter(Q(status="paid") | Q(status="shipped"), ~Q(customer__city="تهران"))

# ساخت پویا از فرم جست‌وجو
cond = Q()
if q := request.GET.get("q"):
    cond &= Q(tracking_code__icontains=q) | Q(customer__full_name__icontains=q)
if city := request.GET.get("city"):
    cond &= Q(customer__city=city)
orders = Order.objects.filter(cond)

F: ارجاع به مقدار ستون

from django.db.models import F

Carpet.objects.filter(stock__lt=F("min_stock"))                 # مقایسه‌ی دو ستون
Carpet.objects.filter(pk=7).update(stock=F("stock") - 1)        # کم کردن اتمیک در SQL
Carpet.objects.update(price=F("price") * 110 / 100)             # افزایش ۱۰٪ همه‌ی قیمت‌ها

stock=F("stock") - 1 در خود پایگاه داده اجرا می‌شود؛ پس اگر دو درخواست هم‌زمان برسند، هیچ‌کدام تغییر دیگری را بازنویسی نمی‌کند (race condition کلاسیک «خواندن، کم کردن، ذخیره» پیش نمی‌آید).

aggregate و annotate

aggregate یک دیکشنری برای کل QuerySet برمی‌گرداند؛ annotate به هر ردیف یک ستون محاسبه‌شده اضافه می‌کند.

from django.db.models import Avg, Count, Sum, BigIntegerField
from django.db.models.functions import Coalesce

Order.objects.filter(status="paid").aggregate(total=Sum("total"), avg=Avg("total"), n=Count("id"))
# {'total': 8450000000, 'avg': 70416666.6, 'n': 120}

vip = (Customer.objects
       .annotate(order_count=Count("orders", distinct=True),
                 spent=Coalesce(Sum("orders__total"), 0, output_field=BigIntegerField()))
       .filter(order_count__gte=3)
       .order_by("-spent"))

values و values_list: فقط همان ستون‌ها

Order.objects.values("status").annotate(n=Count("id"))           # گروه‌بندی بر اساس status
Carpet.objects.values_list("code", flat=True)                      # ['KSH-1200-17', ...]
Carpet.objects.values_list("code", "price", named=True)           # namedtuple

ترتیب مهم است: values() قبل از annotate() یعنی GROUP BY؛ بعد از آن یعنی فقط انتخاب ستون‌ها.

نکته‌هایی که کمتر کسی می‌داند

  • دو Count یا Sum روی دو رابطه‌ی چندتایی مختلف در یک annotate، به دلیل JOIN نتیجه را ضرب می‌کنند؛ برای Count از distinct=True و برای Sum از Subquery (درس ۱۴) استفاده کنید.
  • بعد از obj.stock = F("stock") - 1; obj.save() مقدار obj.stock در پایتون یک عبارت F است، نه عدد؛ قبل از استفاده obj.refresh_from_db(fields=["stock"]) بزنید.
  • Sum روی مجموعه‌ی خالی None برمی‌گرداند نه صفر؛ Coalesce یا در Django 4.0 به بعد پارامتر default=0 خود aggregate را به کار ببرید: Sum("total", default=0).
  • TruncMonth ماه میلادی را برمی‌گرداند؛ برای گزارش ماهانه‌ی شمسی باید بازه‌ی هر ماه شمسی را با jdatetime حساب کنید و با created_at__range فیلتر کنید (پروژه‌ی پایانی).
  • ordering پیش‌فرض Meta روی GROUP BY اثر ندارد (از Django 3.1)، اما order_by() صریح شما وارد GROUP BY می‌شود و ممکن است گروه‌ها را بشکند؛ اگر گروه‌بندی عجیب شد، order_by() خالی بزنید.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.