この記事は Django Advent Calendar 2021 15日目の記事です。
今回は Django を使ったクエリ生成のあれこれについて書きます。
その2としているのは、以前も書いたことがあるからです。
Django、人気ですよね。
独立してから5年ぐらい立ちますが、これまで稼働したクライアントの中にも Django を使ったプロダクトがいくつかありました。
私自身、以前 Django で簡単な Web サイトを作っていました。
Django はフルスタックフレームワークですが、SPA やマイクロサービスといったワードもメジャーになってきた昨今だと API として使われることがほとんどな気がします。
Django は Django REST Framework という api 作成に特化したライブラリもあるので、比較的スムーズに開発できます。
今回紹介するもの
- OR
- SUM + GROUP BY
- RDBMS の独自関数
- (おまけ)WITH
コードはあくまでもクエリ生成部分に絞ったものなので、コード全体を見たいという方は以下リポジトリをご覧ください。
今回はクエリにフォーカスすることもあり、前述の Django REST Framework を使い api として作成しています。
OR
OR 条件に限らず、短いワードってとにかく検索しづらいんですよね。
公式ドキュメントにも記載がありますが、Q
というオブジェクトを利用します(これまた検索しづらい名前ですが…)。
例えば商品タグ (tags) の検索において、code が指定されていた場合はその値を含むものを返す処理を考えます。
ただし、コードは設定しない (null) ということも可能で、そのようなデータは code が指定された場合でも検索結果に含めるものとします。
その場合は以下のようなコードになります。
Django のコード例
from django.db.models import Q
from api.models import Tag
queryset = Tag.objects
code = 'test' # 実際はリクエストパラメータなどから取得
if code:
return queryset.select_related('product').filter(
Q(code__contains=code) | Q(code__isnull=True),
)
return queryset
生成されるクエリ
SELECT
`tags`.`id`,
`tags`.`product_id`,
`tags`.`code`,
`tags`.`name`,
`products`.`id`,
`products`.`name`,
`products`.`type`,
`products`.`price`
FROM
`tags`
INNER JOIN `products` ON
(`tags`.`product_id` = `products`.`id`)
WHERE
(`tags`.`code` LIKE BINARY '%test%'
OR `tags`.`code` IS NULL);
通常、filter 関数に複数のキーワードを含めると論理積 (AND) になります。
OR 検索をしたい場合は Q
関数内に条件を書き、論理和を意味する |
で条件を結合します。
filter 関数を |
でつなぐことでも同じ結果になります。
queryset.filter(code__contains=code) | queryset.filter(code__isnull=True)
SUM + GROUP BY
商品 (products.id) 単位の合計購入金額 (purchase_histories.price) を算出したいケースを考えます。
Django のコード例
from django.db.models import Sum
from django.db.models.functions import Coalesce
# 商品単位で購入金額を合計し、降順に取得
return queryset.select_related('product').values(
'product_id', 'product__name',
).annotate(
total=Coalesce(Sum('price'), 0), # 購入履歴が無い商品は0円とするため Coalesce を利用
).order_by('-total')
生成されるクエリ
SELECT
`purchase_histories`.`product_id`,
`products`.`name`,
COALESCE(SUM(`purchase_histories`.`price`), 0) AS `total`
FROM
`purchase_histories`
INNER JOIN `products` ON
(`purchase_histories`.`product_id` = `products`.`id`)
GROUP BY
`purchase_histories`.`product_id`,
`products`.`name`
ORDER BY
`total` DESC;
シンプルに見えるんですが一つ落とし穴があって、Django のバージョンによっては order_by を設定しないと正しく算出されないようです。
sum だけではなく count など他の集計関数を使った場合も同じことが言えそうです。
Stack Overflow にこの問題に関する質問と回答がありました。
RDBMS の独自関数
ある年ある商品の購入金額を、月単位に取得するケースを考えます。
グラフ表示する際などはこういったフォーマットでデータが返ってくると扱いやすいですよね。
MySQL でクエリを書く場合、DATE_FORMAT や YEAR を使って取得する方法が考えられますが、これを Django で実現する際には少し工夫が必要です。
関数実行には Func
を、フィールドの動的な指定には F
を利用します。
Django のコード例
from django.db.models import CharField, F, Func, IntegerField
# 商品ID: 1における2021年のデータを取得する
product_id = 1
year = 2021
return PurchaseHistory.objects.annotate(
# 年でフィルタするため、YEAR 関数を使って購入日の年のみを算出する
year=Func(
F('purchase_date'),
function='YEAR',
template="%(function)s(%(expressions)s)",
output_field=IntegerField(),
),
# 年月単位でレコードを取得するため、DATE_FORMAT 関数を使って購入年月のみを算出する
year_month=Func(
F('purchase_date'),
function='DATE_FORMAT',
template="%(function)s(%(expressions)s, '%%%%Y/%%%%m')", # % はエスケープする必要がある
output_field=CharField(),
),
).filter(
product_id=product_id,
year=year,
).annotate(
total=Sum('price'), # 購入金額の合計を算出
).order_by('year_month').values('year_month', 'total') # 年月昇順に、年月と合計金額を取得する
生成されるクエリ
SELECT
DATE_FORMAT(`purchase_histories`.`purchase_date`, '%Y/%m') AS `year_month`,
SUM(`purchase_histories`.`price`) AS `total`
FROM
`purchase_histories`
WHERE
(`purchase_histories`.`product_id` = 1
AND YEAR(`purchase_histories`.`purchase_date`) = 2021)
GROUP BY
`purchase_histories`.`id`
ORDER BY
`year_month` ASC;
WITH
mysql 8.0 では再帰 With 句が利用できます。
あるテーブルのレコードが、同一テーブルの親レコードを参照しているようなケースです。
with については Django に同等の関数が存在しないので、RawSQL
を使って生 SQL を書くことで対応します。
Django のコード例
from django.db.models.expressions import RawSQL
tag = 'pc' # 実際はリクエストパラメータなどから取得
tag_subquery = Tag.objects.filter(
product_id=OuterRef('pk'), # サブクエリとして利用するため、OuterRef を利用して親クエリのカラムを参照して結合する
).exclude(
code='test',
)
# 自身を親に持つタグを再帰的に検索して、いずれかが紐づく商品に絞り込む
if tag:
tag_subquery = tag_subquery.filter(
id__in=RawSQL("""
with recursive tag_group(id, parent_id) as (
select
id, parent_id
from
tags where code = %s
union all
select
tags.id, tags.parent_id
from
tags
inner join tag_group on tags.parent_id = tag_group.id
)
select
tag_group.id
from
tag_group
""", [tag]),
)
queryset = Product.objects.annotate(
has_tag=Exists(
tag_subquery.values('id'),
),
).filter(has_tag=True)
生成されるクエリ
SELECT `products`.`id`, `products`.`name`, `products`.`type`, `products`.`price`, EXISTS(SELECT (1) AS `a` FROM `tags` U0 WHERE (U0.`product_id` = (`products`.`id`) AND NOT (U0.`code` = 'test' AND U0.`code` IS NOT NULL) AND U0.`id` IN (
with recursive tag_group(id, parent_id) as (
select
id, parent_id
from
tags where code = 'pc'
union all
select
tags.id, tags.parent_id
from
tags
inner join tag_group on tags.parent_id = tag_group.id
)
select
tag_group.id
from
tag_group
)) LIMIT 1) AS `has_tag` FROM `products` WHERE EXISTS(SELECT (1) AS `a` FROM `tags` U0 WHERE (U0.`product_id` = (`products`.`id`) AND NOT (U0.`code` = 'test' AND U0.`code` IS NOT NULL) AND U0.`id` IN (
with recursive tag_group(id, parent_id) as (
select
id, parent_id
from
tags where code = 'pc'
union all
select
tags.id, tags.parent_id
from
tags
inner join tag_group on tags.parent_id = tag_group.id
)
select
tag_group.id
from
tag_group
)) LIMIT 1) ORDER BY `products`.`type` ASC;
ただ、(Django が提供する関数を駆使して)もう少しうまく書ける気がしています。
詳しい方がいればぜひ教えて下さい。
まとめ
「思った以上にコード量が多くてややこしいな」とか「直接 SQL 書いたほうが早くね?」とか思われる方も多いかも知れませんが、正直私もそう思います。
ただ、Python や Django のサポートを受けながら SQL を組み立てていけるのは十分メリットだと思っています。
SQL は結局のところ文字列なので、コーディング中に気づきづらい(linter とかでチェックしづらい)という問題もあります。
それに 郷に入っては郷に従え という言葉もありますし、覚えていて損はない…はずです。
また機会があれば続編を書きたいと思います。