LoginSignup
5
3

More than 3 years have passed since last update.

Django 多対多の関係先モデルのフィールドでソートする

Last updated at Posted at 2021-04-05

はじめに

  • この記事は、よりDjangoらしくてスマートな解決方法を求めて試行錯誤した記録です。
    • 試行錯誤の末、なんとか成果は得られたのですが、これで良いものか確証はありません。
    • よりスマートな、あるいは、Djangoらしい解法をご存じでしたら、是非コメントをお寄せください。
  • この記事では、DjangoとSQLのチュートリアルを済ませた方を読者として想定しています。

環境

課題

題材

  • 「著者」と「著書」を登録できるモデルを用意して、「書籍の一覧」ビューを作ります。
    • 似た題材で、よく目にするのは、「タグ」と「記事」でしょうか。

モデル

  • 著者
    • 著者情報のテーブルです。
    • フィールド: [ 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サイトのAuthorBookどちら側でも、明示的に指定することで相手側をインライン編集できます。
    • この場合でも、中間テーブルを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 も使う

  • ManyToManyFieldthroughを使うことで、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')も生成されてしまいます。
  • ともかく、この方法だと、次のようにするだけで、AdminサイトのAuthorBookどちら側でも、ナチュラルにインラインで関係を編集できます。
    • ここまで挙げた中では、この方法が良さそうです。
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'のとき、つまり、URL exlibris/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

  • 使えるかなと思ったのですが、サポートされている集計関数がCountMinMaxAvgだけで、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を書けば何でもできる
5
3
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3