はじめに
- この記事は、よりDjangoらしくてスマートな解決方法を求めて試行錯誤した記録です。
- 試行錯誤の末、なんとか成果は得られたのですが、これで良いものか確証はありません。
- よりスマートな、あるいは、Djangoらしい解法をご存じでしたら、是非コメントをお寄せください。
- この記事では、DjangoとSQLのチュートリアルを済ませた方を読者として想定しています。
環境
- Python 3.8.8
- Django 3.1.7
- SQLite 3.28.0
- 以下の記事で構築された環境です。
課題
- 多対多の関係を持つ二つのモデルに対して、一方のインスタンス一覧を他方のフィールドでソートします。
- 多対多の関係のために、間に中間テーブルのモデルが入ります。
- この記事では、「多対多関係の実装方法」と、「関係先モデルのフィールドをキーにしたソート方法」に焦点を当てて手法を探ります。
題材
- 「著者」と「著書」を登録できるモデルを用意して、「書籍の一覧」ビューを作ります。
- 似た題材で、よく目にするのは、「タグ」と「記事」でしょうか。
モデル
- 著者
- 著者情報のテーブルです。
- フィールド: [ ID, 名前, メモ, ]
- 書籍
- 書籍情報のテーブルです。
- フィールド: [ ID, 書名, 発行日, 出版社, 叢書, 価格, 評価, メモ, ]
- 書籍_著者 (ジャンクション)
- 書籍と著書に、多対多の関係を与える中間テーブルです。
- つまり、著者には複数の著作があり、書籍は複数の著者を持つ可能性があります。
- フィールド: [ ID, 書籍のID, 著者のID, ]
- 書籍と著書に、多対多の関係を与える中間テーブルです。
ビュー
- 書籍の一覧
- 自テーブルのフィールドと、関係先の著者名を連結した文字列をキーとして、任意にソートできるようにしたいです。
- 適切なモデルを構築すること、および、後者のソートをよりスマートに実現することが、この記事の課題です。
- 自テーブルのフィールドと、関係先の著者名を連結した文字列をキーとして、任意にソートできるようにしたいです。
- その他省略
多対多関係モデルの実装
ForeignKey を使って、(自前で)中間テーブルを作る
- 実は、
ManyToManyField
を知らなかったので、最初にこの方法を試しました。
exlibris/models.py
from django.core import validators
from django.db import models
class Author(models.Model):
"""著者"""
name = models.CharField(max_length=255)
add_name = models.CharField(max_length=255, blank=True, verbose_name='additional name')
memo = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["name", "add_name"],
name="author_unique"
),
]
def __str__(self):
return self.name
class Book(models.Model):
"""書籍"""
title = models.CharField(max_length=255)
pub_date = models.DateField(null=True, blank=True, verbose_name='date published')
publisher = models.CharField(max_length=255, blank=True)
series = models.CharField(max_length=255, blank=True)
price = models.IntegerField(null=True, blank=True)
evaluation = models.IntegerField(null=True, blank=True, validators=[validators.MinValueValidator(0), validators.MaxValueValidator(100)])
memo = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["title", "publisher", "series"],
name="book_unique"
),
]
def __str__(self):
return self.title
class author_book(models.Model):
"""junction table for Author <-> Book"""
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='book')
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='author')
def __str__(self):
return f"{self.author.name} ⇔ {self.book.title}"
- この方法の場合は、Adminサイトの
Author
、Book
どちら側でも、明示的に指定することで相手側をインライン編集できます。- この場合でも、中間テーブルをAdminサイトで管理する必要は生じません。
exlibris/admin.py
from django.contrib import admin
from .models import Author, Book, author_book
class AuthorInline(admin.TabularInline):
model = author_book
extra = 0
min_num = 1
class BookInline(admin.TabularInline):
model = author_book
extra = 0
class AuthorAdmin(admin.ModelAdmin):
inlines = [BookInline]
class BookAdmin(admin.ModelAdmin):
inlines = [AuthorInline]
admin.site.register(Author, AuthorAdmin)
admin.site.register(Book, BookAdmin)
ForeignKey で中間テーブルを作った上で、ManyToManyField も使う
-
ManyToManyField
でthrough
を使うことで、Djangoに中間テーブルを作らせず自前のモデルを使わせます。- 中間テーブルの
ForeignKey
に逆参照名(related_name
)を定義しようとすると怒られます。
- 中間テーブルの
- 残念ながら、これらの
ManyToManyField
は、Adminサイトでモデルのフィールドとしては扱えず、インライン編集するためには、結局、インラインクラスの定義が必要になります。- 結果的に、前述の中間テーブル側で逆参照名を設定する代わりに、それぞれのモデルで
ManyToManyField
を設定しているだけに見えます。
- 結果的に、前述の中間テーブル側で逆参照名を設定する代わりに、それぞれのモデルで
exlibris/models.py
from django.core import validators
from django.db import models
class Author(models.Model):
"""著者"""
name = models.CharField(max_length=255)
add_name = models.CharField(max_length=255, blank=True, verbose_name='additional name')
books = models.ManyToManyField(Book, through='author_book')
memo = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["name", "add_name"],
name="author_unique"
),
]
def __str__(self):
return self.name
class Book(models.Model):
"""書籍"""
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author, through='author_book')
pub_date = models.DateField(null=True, blank=True, verbose_name='date published')
publisher = models.CharField(max_length=255, blank=True)
series = models.CharField(max_length=255, blank=True)
price = models.IntegerField(null=True, blank=True)
evaluation = models.IntegerField(null=True, blank=True, validators=[validators.MinValueValidator(0), validators.MaxValueValidator(100)])
memo = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["title", "publisher", "series"],
name="book_unique"
),
]
def __str__(self):
return self.title
class author_book(models.Model):
"""junction table for Author <-> Book"""
author = models.ForeignKey(Author, on_delete=models.CASCADE)
book = models.ForeignKey(Book, on_delete=models.CASCADE)
def __str__(self):
return f"{self.author.name} ⇔ {self.book.title}"
ManyToManyField を使って、片側からのみ設定する
- 一方のモデルで
ManyToManyField
を逆参照(related_name
)付きで定義し、他方からは逆参照を使います。
exlibris/models.py
from django.core import validators
from django.db import models
class Author(models.Model):
"""著者"""
name = models.CharField(max_length=255)
add_name = models.CharField(max_length=255, blank=True, verbose_name='additional name')
memo = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["name", "add_name"],
name="author_unique"
),
]
def __str__(self):
return self.name
class Book(models.Model):
"""書籍"""
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author, related_name='books', related_query_name='book')
pub_date = models.DateField(null=True, blank=True, verbose_name='date published')
publisher = models.CharField(max_length=255, blank=True)
series = models.CharField(max_length=255, blank=True)
price = models.IntegerField(null=True, blank=True)
evaluation = models.IntegerField(null=True, blank=True, validators=[validators.MinValueValidator(0), validators.MaxValueValidator(100)])
memo = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["title", "publisher", "series"],
name="book_unique"
),
]
def __str__(self):
return self.title
- この方法では、例えば以下のようにすることで、Adminサイトの
Author
側でもBook
をインライン編集することができます。
py/exlibris/admin.py
from django.contrib import admin
from .models import Author, Book
class BookInline(admin.TabularInline):
model = Book.authors.through
extra = 0
class AuthorAdmin(admin.ModelAdmin):
inlines = [BookInline]
admin.site.register(Author, AuthorAdmin)
admin.site.register(Book)
- この
Book.authors.through
については、公式ドキュメントの「ManyToManyField.through」の項の最後に記載があります。
ManyToManyField を使って、両側から設定する
- 著者と書籍の両方のモデルで
ManyToManyField
を使います。- 先に定義するモデル(
Author
)で中間テーブルを生成して、後から定義するモデル(Book
)ではthrough=Author.books.through
とすることで、Author
が生成したモデルに接続することが可能です。 - 文字列でモデルを指定(
through='author_books'
)して、遅延評価する(後から定義するBook
側で中間テーブルを生成する)ことも可能です。
- 先に定義するモデル(
exlibris/models.py
from django.core import validators
from django.db import models
class Author(models.Model):
"""著者"""
name = models.CharField(max_length=255)
add_name = models.CharField(max_length=255, blank=True, verbose_name='additional name')
memo = models.TextField(blank=True)
books = models.ManyToManyField('Book', blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["name", "add_name"],
name="author_unique"
),
]
def __str__(self):
return self.name
class Book(models.Model):
"""書籍"""
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author, through=Author.books.through)
pub_date = models.DateField(null=True, blank=True, verbose_name='date published')
publisher = models.CharField(max_length=255, blank=True)
series = models.CharField(max_length=255, blank=True)
price = models.IntegerField(null=True, blank=True)
evaluation = models.IntegerField(null=True, blank=True, validators=[validators.MinValueValidator(0), validators.MaxValueValidator(100)])
memo = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["title", "publisher", "series"],
name="book_unique"
),
]
def __str__(self):
return self.title
- モデル
Book
で、逆側で定義された中間テーブル('author_books'
)にthrough
で接続すると、なぜだか、使われないデフォルト名のテーブル('book_authors'
)も生成されてしまいます。- 前述した「自前で中間テーブルを作った上で ManyToManyField も使う」方法だと、同じような
through
設定でもデフォルト名のテーブルは作られたりしないのに、この辺りの法則はよく分かっていません。
- 前述した「自前で中間テーブルを作った上で ManyToManyField も使う」方法だと、同じような
- ともかく、この方法だと、次のようにするだけで、Adminサイトの
Author
、Book
どちら側でも、ナチュラルにインラインで関係を編集できます。- ここまで挙げた中では、この方法が良さそうです。
exlibris/admin.py
from django.contrib import admin
from .models import Author, Book
admin.site.register(Author)
admin.site.register(Book)
書籍リストを著者名でソートする
取り敢えず動いたコード
- 書籍の一覧を著者名でソートしたかったのですが、
GROUP By
の制御が思うようにならず、面倒になって、生のSQLを書きました。 - 以下のコードで、やりたいことは実現できています。
-
sortkey
が'-?author'
のとき、つまり、URLexlibris/book/author/
と~/-author/
に対する挙動が争点になります。
-
モデル
exlibris/models.py
from django.core import validators
from django.db import models
class Author(models.Model):
"""著者"""
name = models.CharField(max_length=255)
add_name = models.CharField(max_length=255, blank=True, verbose_name='additional name')
memo = models.TextField(blank=True)
books = models.ManyToManyField('Book', blank=True)
@property
def book_names(self):
"""著書一覧"""
return ','.join([a.title for a in self.books.all()])
class Meta:
constraints = [
models.UniqueConstraint(
fields = [name, add_name,],
name = 'author_unique'
),
]
def __str__(self):
return self.name
class Book(models.Model):
"""書籍"""
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author, through=Author.books.through)
pub_date = models.DateField(null=True, blank=True, verbose_name='date published')
publisher = models.CharField(max_length=255, blank=True)
series = models.CharField(max_length=255, blank=True)
price = models.IntegerField(null=True, blank=True)
evaluation = models.IntegerField(null=True, blank=True, validators=[validators.MinValueValidator(0), validators.MaxValueValidator(100)])
memo = models.TextField(blank=True)
@property
def author_names(self):
"""執筆者一覧"""
return ','.join([a.name for a in self.authors.all()])
class Meta:
constraints = [
models.UniqueConstraint(
fields = [title, publisher, series,],
name = 'book_unique'
),
]
def __str__(self):
return self.title
URLディスパッチャ
exlibris/urls.py
from django.urls import path
from . import views
app_name = 'exlibris'
urlpatterns = [
path('', views.BookListView.as_view(), name='index'),
path('book/', views.BookListView.as_view(), name='books'),
path('book/<str:sortkey>/', views.BookListView.as_view(), name='sortedbooks'),
# 省略
]
ビュー
exlibris/views.py
from django.views import generic
from .models import Author, Book
class BookListView(generic.ListView):
def get_queryset(self):
objects = Book.objects
sortkey = self.kwargs.get('sortkey')
sign, key = ('-', sortkey[1:]) if sortkey and sortkey[0] == '-' else ('', sortkey)
if key == 'author':
queryset = objects.raw(f"""
SELECT "exlibris_book"."id", "exlibris_book"."title", "exlibris_book"."pub_date", "exlibris_book"."publisher", "exlibris_book"."series", "exlibris_book"."price", "exlibris_book"."evaluation", "exlibris_book"."memo", group_concat("exlibris_author"."name") as "names"
FROM "exlibris_book"
LEFT OUTER JOIN "exlibris_author_book" ON ("exlibris_book"."id" = "exlibris_author_book"."book_id")
LEFT OUTER JOIN "exlibris_author" ON ("exlibris_author_book"."author_id" = "exlibris_author"."id")
GROUP BY "exlibris_book"."id"
ORDER BY "names" {('DESC' if sign else 'ASC')};""")
else:
queryset = objects.order_by(sortkey if key in [field.name for field in Book._meta.get_fields()] else '-pub_date')
#print(queryset.query)
return queryset
# 以降省略
- URLから抽出したソートキーをフィールド名と照合して、一致するものがなければ、デフォルトキーに差し替えます。
- ソートキー
'author'
は別扱いで、生SQLを使います。
- ソートキー
試行錯誤
ソートキー
- 最初は、単純に、ソートキーに
authors__name
と指定してみました。
exlibris/views.py
queryset = objects.order_by(sign+'authors__name')
queryset
SELECT "exlibris_book"."id", "exlibris_book"."title", "exlibris_book"."pub_date", "exlibris_book"."publisher", "exlibris_book"."series", "exlibris_book"."price", "exlibris_book"."evaluation", "exlibris_book"."memo"
FROM "exlibris_book"
LEFT OUTER JOIN "exlibris_author_books" ON ("exlibris_book"."id" = "exlibris_author_books"."book_id")
LEFT OUTER JOIN "exlibris_author" ON ("exlibris_author_books"."author_id" = "exlibris_author"."id")
ORDER BY "exlibris_author"."name" ASC
- この方法だと、まず、書籍×著者のリストが生成されますが、Djangoは、本来の行数(書籍のレコード数)で出力を制限するようです。
- 例えば、書籍が5冊あり、内2冊が2人の共著で、残り3冊のは著者1人だとすると、(2×2+3=)7行が生成されますが、末尾の2行は出力されません。
-
ORDER BY
の前にGROUP BY "exlibris_book"."id"
と挿入できれば、少なくとも行を失うことはないのですが…。
distinct(*fields)
- SQLite3では、行全体の重複をまとめられるだけで、フィールドの指定が使えないらしく、
NotSupportedError
になります。DISTINCT ON fields is not supported by this database backend
annotate
- 使えるかなと思ったのですが、サポートされている集計関数が
Count
、Min
、Max
、Avg
だけで、group_concat
に相当するものがないようです。 - 以下のように、著者の人数でソートすることはできますが…。
exlibris/views.py
from django.db.models import Count
queryset = objects.annotate(Count('author')).order_by('author__count')
queryset
SELECT "exlibris_book"."id", "exlibris_book"."title", "exlibris_book"."pub_date", "exlibris_book"."publisher", "exlibris_book"."series", "exlibris_book"."price", "exlibris_book"."evaluation", "exlibris_book"."memo", COUNT("exlibris_author_books"."author_id") AS "author__count"
FROM "exlibris_book"
LEFT OUTER JOIN "exlibris_author_books" ON ("exlibris_book"."id" = "exlibris_author_books"."book_id")
GROUP BY "exlibris_book"."id", "exlibris_book"."title", "exlibris_book"."pub_date", "exlibris_book"."publisher", "exlibris_book"."series", "exlibris_book"."price", "exlibris_book"."evaluation", "exlibris_book"."memo"
ORDER BY "author__count" ASC
- なるほど、
annotate
を使うとGROUP BY
を使ってくれるのですね。
独自に Aggregate 関数を作る
- 無ければ作ってしまえ…、というか、必要に応じて作る仕組みになっていました。
- キー
author
に対して、group_concat
で集めた著者名の連結文字列をカラムに加え、さらにソートキーに使います。
exlibris/views.py
from django.views import generic
from .models import Author, Book
from django.db.models import Aggregate
class GroupConcat(Aggregate):
function = 'GROUP_CONCAT'
class BookListView(generic.ListView):
def get_queryset(self):
objects = Book.objects
sortkey = self.kwargs.get('sortkey')
sign, key = ('-', sortkey[1:]) if sortkey and sortkey[0] == '-' else ('', sortkey)
if key == 'author':
queryset = objects.annotate(names=GroupConcat('authors__name')).order_by(sign+'names')
else:
queryset = objects.order_by(sortkey if key in [field.name for field in Book._meta.get_fields()] else '-pub_date')
#print(queryset.query)
return queryset
# 以降省略
queryset
SELECT "exlibris_book"."id", "exlibris_book"."title", "exlibris_book"."pub_date", "exlibris_book"."publisher", "exlibris_book"."series", "exlibris_book"."price", "exlibris_book"."evaluation", "exlibris_book"."memo", GROUP_CONCAT("exlibris_author"."name") AS "names"
FROM "exlibris_book"
LEFT OUTER JOIN "exlibris_author_books" ON ("exlibris_book"."id" = "exlibris_author_books"."book_id")
LEFT OUTER JOIN "exlibris_author" ON ("exlibris_author_books"."author_id" = "exlibris_author"."id")
GROUP BY "exlibris_book"."id", "exlibris_book"."title", "exlibris_book"."pub_date", "exlibris_book"."publisher", "exlibris_book"."series", "exlibris_book"."price", "exlibris_book"."evaluation", "exlibris_book"."memo"
ORDER BY "names" ASC
- この方法は良さそうです。
独自にFuncで関数を作る
- 実は、
Aggregate
の前にFunc
を試したのですが、こちらは、通常のDB関数向けで、集計関数に使うのは間違っています。-
GROUP BY
は生成されないし、無理に作ろうとすると集計したカラムまでそこに押し込んでくるので、大変なことになります。
-
- 前述のように
Aggregate
で作ると、ちゃんと解っているらしくて、GROUP BY
を挟みつつ、無茶はしてこないようです。
DBでViewを作る
- DBで
CREATE VIEW
すれば、先のモデルでは@property
を付けてPython側で実装していたauthors
フィールドを、DBのカラムとして実装可能です。- そうすれば、自テーブルのカラムとして、単純にソートキーに使えます。
DBの準備
- まず、アプリに、空のマイグレーションファイルを生成します。
terminal
...>py manage.py makemigrations exlibris --empty
Migrations for 'exlibris':
exlibris\migrations\0019_auto_20210404_1824.py
- すると、テンプレ状態のマイグレーションファイルができるので、以下のような感じで書き加えます。
-
exlibris_bookview
というビューを作ります。- 既存のテーブルと同名のビューを作ると、Djangoが混乱するので、そういうチャレンジはやらぬが吉です。
- ロールバックできるように、ビューを
DROP
するSQLも書きます。
exlibris/migrations/0019_auto_20210404_1824.py
# Generated by Django 3.1.7 on 2021-04-04 09:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('exlibris', '0018_auto_20210404_0325'),
]
create_sql = """
CREATE VIEW IF NOT EXISTS "exlibris_bookview" ("id", "title", "author", "pub_date", "publisher", "series", "price", "evaluation", "memo")
AS SELECT "exlibris_book"."id", "exlibris_book"."title", group_concat("exlibris_author"."name") as "author", "exlibris_book"."pub_date", "exlibris_book"."publisher", "exlibris_book"."series", "exlibris_book"."price", "exlibris_book"."evaluation", "exlibris_book"."memo"
FROM "exlibris_book"
LEFT OUTER JOIN "exlibris_author_book" ON ("exlibris_book"."id" = "exlibris_author_book"."book_id")
LEFT OUTER JOIN "exlibris_author" ON ("exlibris_author_book"."author_id" = "exlibris_author"."id")
GROUP BY "exlibris_book"."id";
"""
drop_sql = """DROP VIEW IF EXISTS "exlibris_bookview";"""
operations = [
migrations.RunSQL(create_sql, drop_sql),
]
- マイグレートを実施します。
terminal
...>py manage.py migrate
モデルの生成
- モデルに以下のクラスを追加して、
Meta
クラスで管理対象から外し(managed = False
)ます。- このクラスは、DB側の制約で、読み出しにしか使えません。
exlibris/models.py
class Bookview(models.Model):
"""書籍 (読み出し専用)"""
id = models.AutoField(primary_key=True, editable=False)
title = models.CharField(max_length=255, editable=False)
author = models.TextField(editable=False)
pub_date = models.DateField(null=True, blank=True, verbose_name='date published', editable=False)
publisher = models.CharField(max_length=255, blank=True, editable=False)
series = models.CharField(max_length=255, blank=True, editable=False)
price = models.IntegerField(null=True, blank=True, editable=False)
evaluation = models.IntegerField(null=True, blank=True, validators=[validators.MinValueValidator(0), validators.MaxValueValidator(100)], editable=False)
memo = models.TextField(blank=True, editable=False)
class Meta:
managed = False
def __str__(self):
return self.title
ビューの生成
- あとは、ビューを以下のようにしてやれば、モデル
Book
用のテンプレ(book_list.html
)が使い回せるはずです。
exlibris/views.py
from django.views import generic
from .models import Author, Book, Bookview
class BookListView(generic.ListView):
template_name = 'exlibris/book_list.html' # テンプレ名を上書き
def get_queryset(self):
objects = Bookview.objects
sortkey = self.kwargs.get('sortkey')
sign, key = ('-', sortkey[1:]) if sortkey and sortkey[0] == '-' else ('', sortkey)
queryset = objects.order_by(sortkey if key in [field.name for field in Book._meta.get_fields()] else '-pub_date')
#print(queryset.query)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['book_list'] = context.pop('bookview_list', None) # コンテキスト変数のキー名を変更
return context
結果
-
sortkey = 'author'
に対して、以下が得られます。
queryset
SELECT "exlibris_bookview"."id", "exlibris_bookview"."title", "exlibris_bookview"."author", "exlibris_bookview"."pub_date", "exlibris_bookview"."publisher", "exlibris_bookview"."series", "exlibris_bookview"."price", "exlibris_bookview"."evaluation", "exlibris_bookview"."memo"
FROM "exlibris_bookview"
ORDER BY "exlibris_bookview"."author" ASC
- このやり方は、きちんと動作しますし、ビューがシンプルで分かり易いのですが、モデルが読み書きで二系統になるのでややこしいですね。
- この例のようなシンプルな題材ではなく、より複雑なモデルを作りたい場合には選択肢になるかも知れません。
- とはいっても、普段使いするビューに書くか、普段は見えないマイグレーションに書くか、という違いだけで、生のSQLを書いていることに変わりないのですが。
おわりに
- 執筆者は、Python、Djangoともに初学者レベルですので、誤りもあるかと思います。
- お気づきの際は、是非コメントや編集リクエストにてご指摘ください。
- あるいは、「それでも解らない」、「自分はこう捉えている」などといった、ご意見、ご感想も歓迎いたします。
大雑把なまとめ
- 多対多の関係を作るときは、一方のモデルで暗に中間テーブルを生成させ、他方からも同じテーブルに接続して使うのが便利
- 多対多の関係先のフィールドでソートする際に、DBがサポートする集計関数がDjango側に用意されていなければ、自分で定義して使うのが便利
- 直にSQLを書けば何でもできる