フィールドルックアップで多対多のフィールドを指定してタグ検索を実装しようとした場合に、使い方によっては意図とは異なる結果出ることがあったので、各ケースでの結果をメモしておきます。
モデル
以下のようにモデルを定義し、
Post(投稿)にTag(タグ)を紐づけてタグ名で絞り込み検索を行いたいとします。
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=100)
body = models.TextField()
tags = models.ManyToManyField("Tag", blank=True)
class Tag(models.Model):
name = models.CharField(max_length=100)
一つのPostは複数のTagを持つことができ、一つのTagは複数のPostと紐づくことができるので、モデル間の関係は多対多の関係となります。従って、Postモデルが複数のTagインスタンスを参照するManyToManyField「tags」を持ちます。
上記のモデル定義で初回のマイグレーションファイルを作成すると、対応するSQLは以下のようになります(app名は省略、データベースはSQLite)。
BEGIN;
--
-- Create model Tag
--
CREATE TABLE "tag" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(100) NOT NULL);
--
-- Create model Post
--
CREATE TABLE "post" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "title" varchar(100) NOT NULL, "body" text NOT NULL);
CREATE TABLE "post_tags" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "post_id" bigint NOT NULL REFERENCES "post" ("id") DEFERRABLE INITIALLY DEFERRED, "tag_id" bigint NOT NULL REFERENCES "tag" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE UNIQUE INDEX "post_tags_post_id_tag_id_8e248081_uniq" ON "post_tags" ("post_id", "tag_id");
CREATE INDEX "post_tags_post_id_082a0a4e" ON "post_tags" ("post_id");
CREATE INDEX "post_tags_tag_id_1a619a6d" ON "post_tags" ("tag_id");
COMMIT;
※管理コマンドのsqlmigrate
で確認可能。
補足:検証用に以下のようなデータを追加しました。
データ
[
{
"model": "post",
"pk": 1,
"fields": {
"title": "Ruby on Railsについてのあれこれ",
"body": "Ruby on Railsについてのあれこれ本文",
"tags": [
1
]
}
},
{
"model": "post",
"pk": 2,
"fields": {
"title": "Vue.jsについてのあれこれ",
"body": "Vue.jsについてのあれこれ本文",
"tags": [
2
]
}
},
{
"model": "post",
"pk": 3,
"fields": {
"title": "Pythonについてのあれこれ",
"body": "Pythonについてのあれこれ本文",
"tags": [
3
]
}
},
{
"model": "post",
"pk": 4,
"fields": {
"title": "Ruby on RailsとVue.jsについてのあれこれ",
"body": "Ruby on RailsとVue.jsについてのあれこれ本文",
"tags": [
1,
2
]
}
},
{
"model": "post",
"pk": 6,
"fields": {
"title": "Ruby on RailsとVue.jsとPythonについてのあれこれ",
"body": "Ruby on RailsとVue.jsとPythonについてのあれこれ本文",
"tags": [
1,
2,
3
]
}
},
{
"model": "post",
"pk": 7,
"fields": {
"title": "Ruby on RailsとPythonについてのあれこれ",
"body": "Ruby on RailsとPythonについてのあれこれ本文",
"tags": [
1,
3
]
}
},
{
"model": "post",
"pk": 8,
"fields": {
"title": "Vue.jsとPythonについてのあれこれ",
"body": "Vue.jsとPythonについてのあれこれ本文",
"tags": [
2,
3
]
}
},
{
"model": "tag",
"pk": 1,
"fields": {
"name": "Ruby on Rails"
}
},
{
"model": "tag",
"pk": 2,
"fields": {
"name": "Vue.js"
}
},
{
"model": "tag",
"pk": 3,
"fields": {
"name": "Python"
}
}
]
前提
本題に入る前にフィールドルックアップで多対多のフィールドを参照する方法について整理しておきます。
公式ドキュメントでは「リレーションを横断するルックアップ」という箇所で、
Django offers a powerful and intuitive way to "follow" relationships in lookups, taking care of the SQL JOINs for you automatically, behind the scenes. To span a relationship, use the field name of related fields across models, separated by double underscores, until you get to the field you want.
と記述されています。
というわけでモデルクラスで定義した多対多のフィールド名(今回はPostモデルのtagsフィールド)をそのまま入れることで参照できます。そこからアンダースコアを2つつなげて別のモデルインスタンスのフィールドにまでアクセスできます。
# 例
queryset = Post.objects.filter(tags__name__exact="Some Tag Name")
また、このようにリレーションを横断するルックアップではSQLでの結合操作が自動的に行われます。見方を変えればこのようなフィールドルックアップを使ったQuerySetでは裏でテーブル結合が行われていることを意識しなければならないっぽいですね。
続きで以下のようにも書かれており、
It works backwards, too. While it can be customized, by default you refer to a "reverse" relationship in a lookup using the lowercase name of the model.
多対多などの関係のフィールドを定義していない側のモデル(今回はTagモデル)からリレーションを横断する際は、参照先のモデル名を小文字(今回は「post」)で入れます。
# 例
queryset = Tag.objects.filter(post__body__contains="ネットワーク")
補足
リレーションを横断する際に横断先のモデルのフィールドを指定しない場合は、プライマリーキーかモデルのインスタンスで比較が行われます。また、ダブルアンダースコアで「__pk」とすることでそのモデルのプライマリーキーでの比較が行えます。
#例 以下はいずれも同じQuerySetになる
tag = Tag.object.get(pk=1)
queryset_1 = Post.objects.filter(tags__exact=tag)
queryset_2 = Post.objects.filter(tags__exact=tag.id)
queryset_3 = Post.objects.filter(tags__pk__exact=tag.id)
※関係オブジェクトを横断したクエリについて
https://docs.djangoproject.com/ja/4.1/topics/db/queries/#queries-over-related-objects
※pk ルックアップショートカットについて
https://docs.djangoproject.com/ja/4.1/topics/db/queries/#the-pk-lookup-shortcut
1つのタグのみで絞り込む
絞り込み方法は、
PostモデルからQuerySetを作成してフィールドルックアップでtagsフィールドからタグのidを参照して絞り込みしたいidと比較する、というやり方になるかと思います。
# tag_id = 1 (Ruby on Rails) で絞り込み
queryset = Post.objects.filter(tags__id__exact=1)
print(queryset)
"""
<QuerySet [<Post: Ruby on Railsについてのあれこれ>,
<Post: Ruby on RailsとVue.jsについてのあれこれ>,
<Post: Ruby on RailsとVue.jsとPythonについてのあれこれ>,
<Post: Ruby on RailsとPythonについてのあれこれ>]>
"""
上記のコードを実行すると以下のSQLが発行されました(django debug toolbarで確認)
SELECT "post"."id",
"post"."title",
"post"."body"
FROM "post"
INNER JOIN "post_tags"
ON ("post"."id" = "post_tags"."post_id")
WHERE "post_tags"."tag_id" = '1'
postテーブルとpost_tagsテーブルを結合してpost_tags.tag_idで絞り込みを行っているので、概ね意図した通りの検索が行われていると思います。
ちなみに、フィールドルックアップでtagsフィールドで参照しているTagモデルのフィールドまで参照した場合ですが、以下のようにタグ名でフィルタを掛けても意図した通りの結果になります。
# タグ名 Ruby on Rails で絞り込み
queryset = Post.objects.filter(tags__name__exact="Ruby on Rails")
print(queryset)
"""
<QuerySet [<Post: Ruby on Railsについてのあれこれ>,
<Post: Ruby on RailsとVue.jsについてのあれこれ>,
<Post: Ruby on RailsとVue.jsとPythonについてのあれこれ>,
<Post: Ruby on RailsとPythonについてのあれこれ>]>
"""
こちらのコードは実行すると以下のSQLが発行されました。今度はpost_tagsテーブルとtagテーブルも結合しています。
SELECT "post"."id",
"post"."title",
"post"."body"
FROM "post"
INNER JOIN "post_tags"
ON ("post"."id" = "post_tags"."post_id")
INNER JOIN "tag"
ON ("post_tags"."tag_id" = "tag"."id")
WHERE "tag"."name" = '''Ruby on Rails'''
公式ドキュメントの記載通り、必要に応じて自動的にテーブル結合が行われていますね。
複数のタグでAND検索
複数のタグを指定し、全てのタグを持つ投稿のみに絞り込む場合を考えます。
絞り込み方法は、1つのタグで絞り込みした結果をさらに次のタグで絞り込みする…というのをタグの数分だけ繰り返す方法になるかと思います。
# tag_id = 1, 2, 3でAND検索
queryset = Post.objects.all()
tags_id = [1, 2, 3]
for tag_id in tags_id:
queryset = queryset.filter(tags__id__exact=tag_id)
print(queryset)
"""
<QuerySet [<Post: Ruby on RailsとVue.jsとPythonについてのあれこれ>]>
"""
上記のコードを実行すると以下のSQLが発行されました(django debug toolbarで確認)
SELECT "post"."id",
"post"."title",
"post"."body"
FROM "post"
INNER JOIN "post_tags"
ON ("post"."id" = "post_tags"."post_id")
INNER JOIN "post_tags" T4
ON ("post"."id" = T4."post_id")
INNER JOIN "post_tags" T6
ON ("post"."id" = T6."post_id")
WHERE ("post_tags"."tag_id" = '1' AND T4."tag_id" = '2' AND T6."tag_id" = '3')
filter()メソッドを呼び出した分だけpost_tagsテーブルと結合しています。
少し長くなりますが、こちらのSQLからpost.id、post_tags.tag_id以外の列およびWHERE句を除外して実行すると以下のような表が作成されます。
表
post.id | post_tags.tag_id | T4.tag_id | T6.tag_id |
---|---|---|---|
1 | 1 | 1 | 1 |
2 | 2 | 2 | 2 |
3 | 3 | 3 | 3 |
4 | 1 | 1 | 1 |
4 | 1 | 1 | 2 |
4 | 1 | 2 | 1 |
4 | 1 | 2 | 2 |
4 | 2 | 1 | 1 |
4 | 2 | 1 | 2 |
4 | 2 | 2 | 1 |
4 | 2 | 2 | 2 |
6 | 1 | 1 | 1 |
6 | 1 | 1 | 2 |
6 | 1 | 1 | 3 |
6 | 1 | 2 | 1 |
6 | 1 | 2 | 2 |
6 | 1 | 2 | 3 |
6 | 1 | 3 | 1 |
6 | 1 | 3 | 2 |
6 | 1 | 3 | 3 |
6 | 2 | 1 | 1 |
6 | 2 | 1 | 2 |
6 | 2 | 1 | 3 |
6 | 2 | 2 | 1 |
6 | 2 | 2 | 2 |
6 | 2 | 2 | 3 |
6 | 2 | 3 | 1 |
6 | 2 | 3 | 2 |
6 | 2 | 3 | 3 |
6 | 3 | 1 | 1 |
6 | 3 | 1 | 2 |
6 | 3 | 1 | 3 |
6 | 3 | 2 | 1 |
6 | 3 | 2 | 2 |
6 | 3 | 2 | 3 |
6 | 3 | 3 | 1 |
6 | 3 | 3 | 2 |
6 | 3 | 3 | 3 |
7 | 1 | 1 | 1 |
7 | 1 | 1 | 3 |
7 | 1 | 3 | 1 |
7 | 1 | 3 | 3 |
7 | 3 | 1 | 1 |
7 | 3 | 1 | 3 |
7 | 3 | 3 | 1 |
7 | 3 | 3 | 3 |
8 | 2 | 2 | 2 |
8 | 2 | 2 | 3 |
8 | 2 | 3 | 2 |
8 | 2 | 3 | 3 |
なぜ、INNER JOINで同じテーブルを複数回結合するのかは今の所わかりません(詳しい方教えてください)。でも確かに、指定したタグの数分だけカラムを右側に増やしていかないと複数タグによるAND検索が出来ないんですよね。
結果としてこの表内でWHERE ("post_tags"."tag_id" = '1' AND T4."tag_id" = '2' AND T6."tag_id" = '3')
を満たす行はpost.idが6の1行だけで、投稿「Ruby on RailsとVue.jsとPythonについてのあれこれ」だけに絞り込まれているので意図した通りになっているかと思われます。
代替コードを検討してみた
直観的にはINNER JOINを繰り返さずに1つのpost_tagsテーブルだけで指定した複数のタグ全てを持つ投稿だけに絞り込みたいです。
まず、post_tagsテーブルを指定したタグを持つ行だけに絞り込みます。
/* id:1のタグとid:2のタグでAND検索したい */
SELECT * FROM "post_tags" WHERE "tag_id" = '1' OR "tag_id" = '2'
しかし、この後に「id:1のタグ」と「id:2のタグ」を両方持つ投稿に絞り込むええ感じの方法がありません。
辛うじて思いついたのは、上記の結果を同じpost_idで集約して行数をカウントして、カウント数が指定したタグ数と同じグループのpost_idのみを抽出する方法です。
/* id:1のタグとid:2のタグでAND検索したい */
SELECT "post_id" FROM "post_tags" WHERE "tag_id" = '1' OR "tag_id" = '2'
GROUP BY "post_id"
HAVING COUNT(post_id) = 2
QuerySetで同じようなことをしようとすると、以下のようになるかと思います。
タグの数分だけ条件式を動的にORでつなげる必要があるので、少し変則的なコードになりました。また、GROUP BY句とHAVING句と同様のことを行うためにannotate()メソッドを利用しました。
# 空のquerysetを生成
queryset = Post.objects.none()
tags_id = [1, 2, 3]
# この時点でpost_tagsテーブルと結合される
for tag_id in tags_id:
queryset = queryset | Post.objects.filter(tags__id__exact=tag_id)
# post.idでグループ化してCOUNT(post.id)で集計
# 集計結果は"post_count"というフィールド名でアクセス可能
queryset = queryset.annotate(post_count=Count("id")).filter(
post_count__exact=len(tags_id)
)
print(queryset)
"""
<QuerySet [<Post: Ruby on RailsとVue.jsとPythonについてのあれこれ>]>
"""
上記のコードを実行すると以下のSQLが発行されました(django debug toolbarで確認)
SELECT "post"."id",
"post"."title",
"post"."body",
COUNT("post"."id") AS "post_count"
FROM "post"
INNER JOIN "post_tags"
ON ("post"."id" = "post_tags"."post_id")
WHERE ("post_tags"."tag_id" = '1' OR "post_tags"."tag_id" = '2' OR "post_tags"."tag_id" = '3')
GROUP BY "post"."id",
"post"."title",
"post"."body"
HAVING COUNT("post"."id") = '3'
概ね意図した通りのSQLが発行されています。INNER JOINを複数回繰り返すことに違和感がある場合には代替手段として使えると思います。
複数のタグでOR検索
ダメな例
直観的には以下のようなQuerySetを作ります。
# 空のQuerySetを作る
queryset = Post.objects.none()
tags_id = [1, 2]
# タグの数だけ動的に条件式をORでつなぐ
for tag_id in tags_id:
queryset = queryset | Post.objects.filter(tags__id__exact=tag_id)
しかし、実際にこれを実行するとpostテーブルとpost_tagsテーブルが結合されるので、タグの数の分だけ同じ投稿が重複して表示されてしまいます。
以下、実際に発行されるSQLです。
SELECT "post"."id",
"post"."title",
"post"."body"
FROM "post"
INNER JOIN "post_tags"
ON ("post"."id" = "post_tags"."post_id")
WHERE ("post_tags"."tag_id" = '1' OR "post_tags"."tag_id" = '2')
自分はこのような実装を行い意図しない結果を出してしまいました。
意図した通りにOR検索できる例
上の例に加えてdistinct()メソッドで重複を除外します。
# 空のQuerySetを作る
queryset = Post.objects.none()
tags_id = [1, 2]
# タグの数だけ動的に条件式をORでつなぐ
for tag_id in tags_id:
queryset = queryset | Post.objects.filter(tags__id__exact=tag_id)
# 同じPostインスタンスの行は1つに集約する
queryset = queryset.distinct()
これにより以下のSQLが発行されるようになります。
SELECT DISTINCT "post"."id",
"post"."title",
"post"."body"
FROM "post"
INNER JOIN "post_tags"
ON ("post"."id" = "post_tags"."post_id")
WHERE ("post_tags"."tag_id" = '1' OR "post_tags"."tag_id" = '2')
SELECT DISTINCT句によりpost.id、post.title、post.bodyそれぞれの列の値が同じ行は削除されるため、意図した通りに重複が除外されるようになります。
終わり
よりよい実装方法や間違いがありましたら教えてください。