ORM

Django ORMの結合集計入門|annotateとvaluesでGROUP BY

Django ORMで関連モデルを結合し、グループ化して集計する方法を解説します。annotate()values()を組み合わせると、SQLのJOIN + GROUP BYに相当する複雑なクエリもPythonコードで簡潔に書けます。

基本的な使い方

社員ごとの日別売上合計を取得する例です。

views.py
from django.db.models import Sum
from .models import Employee

def index(request):
    # 社員ごと・日付ごとの売上合計を取得
    employees_with_sales = Employee.objects.values(
        'id', 'name', 'sale__sales_date'
    ).annotate(
        total_sales=Sum('sale__amount')
    ).order_by('id', 'sale__sales_date')

    for row in employees_with_sales:
        print(f"{row['name']} ({row['sale__sales_date']}): {row['total_sales']}円")
実行結果
山田太郎 (2025-01-01): 15000円
山田太郎 (2025-01-02): 25000円
山田太郎 (2025-01-03): 18000円
佐藤花子 (2025-01-01): 22000円
佐藤花子 (2025-01-02): 19500円
発行されるSQL
SELECT employee.id, employee.name, sale.sales_date,
       SUM(sale.amount) AS total_sales
FROM employee
INNER JOIN sale ON employee.id = sale.employee_id
GROUP BY employee.id, employee.name, sale.sales_date
ORDER BY employee.id, sale.sales_date

クエリの構造を理解する

この複雑なクエリを分解して理解しましょう。

views.py
# ステップ1: values()でGROUP BYの対象を指定
Employee.objects.values('id', 'name', 'sale__sales_date')
# → GROUP BY employee.id, employee.name, sale.sales_date

# ステップ2: annotate()で集計関数を適用
.annotate(total_sales=Sum('sale__amount'))
# → SELECT ... SUM(sale.amount) AS total_sales

# ステップ3: order_by()で並べ替え
.order_by('id', 'sale__sales_date')
# → ORDER BY employee.id, sale.sales_date

values()annotate()の前に書くと、values()で指定したフィールドがGROUP BYの対象になります。これがDjango ORMでGROUP BYを表現する方法です。

売上のない社員も含める

INNER JOINでは関連データがないレコードは除外されます。全社員を含めるには工夫が必要です。

views.py
from django.db.models import Sum, Count, Value
from django.db.models.functions import Coalesce

# annotate()を直接使う(LEFT OUTER JOINになる)
all_employees = Employee.objects.annotate(
    total_sales=Coalesce(Sum('sale__amount'), Value(0)),
    sales_count=Count('sale')
).order_by('name')

for emp in all_employees:
    print(f"{emp.name}: {emp.total_sales}円 ({emp.sales_count}件)")
実行結果
山田太郎: 58000円 (3件)
佐藤花子: 41500円 (2件)
田中一郎: 0円 (0件)

Coalesce()を使ってNULL値を0に変換しています。売上がない田中一郎も結果に含まれます。

複数の集計関数を同時に使う

1つのクエリで複数の集計を同時に行えます。

views.py
from django.db.models import Sum, Count, Avg, Max, Min

# 社員ごとの詳細な売上統計
employee_stats = Employee.objects.annotate(
    total_sales=Sum('sale__amount'),
    sales_count=Count('sale'),
    avg_sale=Avg('sale__amount'),
    max_sale=Max('sale__amount'),
    min_sale=Min('sale__amount')
).filter(
    sales_count__gt=0
).order_by('-total_sales')

期間を指定した集計

filter()で条件を絞り込んでから集計を行います。

views.py
from django.db.models import Sum, Count
from datetime import date

# 2025年1月の社員ごとの売上集計
monthly_stats = Employee.objects.filter(
    sale__sales_date__year=2025,
    sale__sales_date__month=1
).values('id', 'name').annotate(
    total_sales=Sum('sale__amount'),
    sales_count=Count('sale')
).order_by('-total_sales')

部署情報も含めた集計

さらに別のモデル(部署)も結合して集計できます。

views.py
from django.db.models import Sum, Count

# 部署ごとの売上集計
department_stats = Employee.objects.values(
    'department__name'
).annotate(
    employee_count=Count('id', distinct=True),
    total_sales=Sum('sale__amount')
).order_by('-total_sales')

for dept in department_stats:
    print(f"{dept['department__name']}: "
          f"{dept['employee_count']}人, "
          f"{dept['total_sales']}円")
実行結果
営業部: 5人, 850000円
技術部: 3人, 320000円
人事部: 2人, 150000円

実践的な使用例

日次売上レポートを構築する実践例です。

views.py
from django.shortcuts import render
from django.db.models import Sum, Count, Avg
from django.db.models.functions import Coalesce
from datetime import date, timedelta
from .models import Employee

def daily_sales_report(request):
    # 日付範囲の処理
    start = request.GET.get('start')
    end = request.GET.get('end')
    start_date = date.fromisoformat(start) if start else date.today() - timedelta(days=30)
    end_date = date.fromisoformat(end) if end else date.today()

    # 社員ごと・日付ごとの売上集計
    daily_sales = Employee.objects.filter(
        sale__sales_date__range=(start_date, end_date)
    ).values(
        'id', 'name', 'sale__sales_date'
    ).annotate(
        total_sales=Sum('sale__amount'),
        transaction_count=Count('sale')
    ).order_by('name', 'sale__sales_date')

    # 社員ごとの合計
    employee_totals = Employee.objects.filter(
        sale__sales_date__range=(start_date, end_date)
    ).annotate(
        total_sales=Coalesce(Sum('sale__amount'), 0),
        avg_daily=Avg('sale__amount')
    ).order_by('-total_sales')

    return render(request, 'reports/daily_sales.html', {
        'daily_sales': daily_sales,
        'employee_totals': employee_totals,
        'start_date': start_date,
        'end_date': end_date,
    })
ポイント

values().annotate()の順序が重要です。values()を先に書くとGROUP BYとして機能し、annotate()を先に書くと各レコードに集計値が追加されます。目的に応じて使い分けましょう。

注意

Count()で重複を除外したい場合はCount('id', distinct=True)を使います。JOINにより同じレコードが複数回カウントされることがあるため、必要に応じてdistinct=Trueを付けてください。

まとめ

  • values().annotate()でGROUP BY + 集計(SUM, COUNT等)を実現できる
  • values()annotate()の前に書くとGROUP BYの対象になる
  • Coalesce()でNULL値のデフォルト値を設定できる
  • 関連モデルのフィールドはダブルアンダースコアでアクセスする
  • distinct=TrueでJOINによる重複カウントを防げる