django で DB のカラムの値によって join 先のテーブルを変更

ここでは、django で models.py に Book モデルと Article モデルを定義し、Author モデルを Book モデル、Article モデルに結合する場合を考える。


# やりたいこと

ひとつのモデルから複数のモデルを参照する際に個別のモデル毎のフィールドで参照せず、オブジェクトの種別とIDで参照を行う。

例えば、Author に target_type フィールド、target_id フィールドを作成し、target_type=Book, target_id=1 の場合には Book モデルの id=1 のオブジェクトを参照する。
同様に、target_type=Article, target_id=2 の場合には Article モデルの id=2 のオブジェクトを参照する。


# django のディレクトリ構成

以下で django のプロジェクト、アプリケーションを作成。

```bash:
django-admin startproject db_test2

cd db_test2
python manage.py startapp test1

cd test1
mkdir -p management/commands
touch management/__init__.py
```

db_test2/settings.py に以下を記述。

* INSTALLED_APPS = [...] に 'test1' を追加。
* DATABASES に postgres のユーザ、パスワード等を記述。


# モデルの定義

Author から Book、Article を参照する。
Author の target_type にはmodels.ForeignKey() で ContentType を指定し、target には target_type, target_id の GenericForeignKey() を指定する。

```python:test1/models.py
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType


class Book(models.Model):
    title = models.CharField(max_length=128)

    def __str__(self):
        return self.title


class Article(models.Model):
    headline = models.CharField(max_length=128)

    def __str__(self):
        return self.headline


class Author(models.Model):
    name = models.CharField(max_length=128)
    target_id = models.PositiveIntegerField()
    target_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    target = GenericForeignKey('target_type', 'target_id')

    def __str__(self):
        return self.name
```

# 登録

## プログラム
以下のレコードを登録する。

```
Book(title='title1')          --  Author(name='book1_author')
Book(title='title2)           --  Author(name='book2_author')
Aritle(headline='headline1')  --  Author(name='article1_author')
Aritle(headline='headline2')  --  Author(name='article2_author')
```

プログラムは以下の通り。

```python:test1/management/commands/create_records.py
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from test1.models import Book, Article, Author


class Command(BaseCommand):
    def handle(self, *args, **options):
        # book
        book1 = Book.objects.create(title='title1')
        book2 = Book.objects.create(title='title2')

        # article
        article1 = Article.objects.create(headline='headline1')
        article2 = Article.objects.create(headline='headline2')

        # book author
        book1_author = Author.objects.create(
            name='book1_author',
            target_id=book1.id,
            target_type=ContentType.objects.get_for_model(Book)
        )

        book2_author = Author.objects.create(
            name='book2_author',
            target_id=book2.id,
            target_type=ContentType.objects.get_for_model(Book)
        )

        # article author
        article1_author = Author.objects.create(
            name='article1_author',
            target_id = article1.id,
            target_type=ContentType.objects.get_for_model(Article)
        )

        article2_author = Author.objects.create(
            name='article2_author',
            target_id = article2.id,
            target_type=ContentType.objects.get_for_model(Article)
        )
```

## 登録実行

```
python manage.py create_records
```

# 検索

## プログラム

* Book の target_type（book_target_type）を取得（book_target_type = ...）
* Author の target_type が Book のレコードを取得（book_authors = ...）
* Author を book.id で参照できるよう、book.id をキーとする辞書を生成（book_author_dict = ...）
* Book と Author を結合したレコードを生成（book_atuhor_records = ...）

```python:test1/management/commands/select_books.py
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from test1.models import Book, Article, Author


class Command(BaseCommand):
    def handle(self, *args, **options):
        # book
        book_target_type = ContentType.objects.get_for_model(Book)
        book_authors = Author.objects.filter(target_type=book_target_type)
        book_author_dict = {author.target_id: author for author in book_authors}
        book_author_records = [
            {'book': book, 'author': book_author_dict.get(book.id)}
            for book in book_target_type.model_class().objects.all()
        ]
        print(book_author_records)
```

## 検索実行

```
python manage.py select_books
```

## 検索結果
※見やすさのため改行をいれている。

```
[{'book': <Book: title1>, 'author': <Author: book1_author>},
 {'book': <Book: title2>, 'author': <Author: book2_author>}
]
```

# DB の登録内容

DB には以下のようにデータが登録されている。

```sql:
db_test2=# select * from test1_book;
 id | title
----+--------
  8 | title1
  9 | title2


db_test2=# select * from test1_article;
 id | headline
----+-----------
  7 | headline1
  8 | headline2


db_test2=# select * from test1_author;
 id |      name       | target_id | target_type_id
----+-----------------+-----------+----------------
  8 | book1_author    |         8 |              8
  9 | book2_author    |         9 |              8
 10 | article1_author |         7 |              7
 11 | article2_author |         8 |              7
```

target_type_id は Book=8、Article=7 となっている。

ここで、test1_author テーブルを見てみると以下のように定義されている。

```sql:
db_test2=# \d test1_author
                                    Table "public.test1_author"
     Column     |          Type          | Collation | Nullable |             Default
----------------+------------------------+-----------+----------+----------------------------------
 id             | bigint                 |           | not null | generated by default as identity
 name           | character varying(128) |           | not null |
 target_id      | integer                |           | not null |
 target_type_id | integer                |           | not null |
Indexes:
    "test1_author_pkey" PRIMARY KEY, btree (id)
    "test1_author_target_type_id_20276ada" btree (target_type_id)
Check constraints:
    "test1_author_target_id_check" CHECK (target_id >= 0)
Foreign-key constraints:
    "test1_author_target_type_id_20276ada_fk_django_content_type_id" FOREIGN KEY (target_type_id) REFERENCES django_content_type(id) DEFERRABLE INITIALLY DEFERRED
```

Foreign-key で参照されている django_content_type の登録内容は以下の通り。

```sql:
db_test2=# select * from django_content_type;
 id |  app_label   |    model
----+--------------+-------------
  1 | admin        | logentry
  2 | auth         | permission
  3 | auth         | group
  4 | auth         | user
  5 | contenttypes | contenttype
  6 | sessions     | session
  7 | test1        | article
  8 | test1        | book
  9 | test1        | author
```

book=8、article=7 となっており、test1_author.target_type_id と一致している。

# 検索 (select_related)
※2025/05/06 追記
select_related を使用すると簡潔に記述することができます。

## プログラム

```python:
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from test1.models import Book, Article, Author


class Command(BaseCommand):
    def handle(self, *args, **options):
        # target_type = book
        book_target_type = ContentType.objects.get_for_model(Book)

        # book author
        book_authors = Author.objects.filter(
            target_type=book_target_type
        ).select_related(
            'target_type'
        )

        # book + author
        for author in book_authors:
            book = author.target
            print(f"{book.title} -- {author}")
```

## 実行結果
```text:
title1 -- book1_author
title2 -- book2_author
```