Django 5.2で追加される複合主キーサポートを試す

Django 5.2 alpha 1がリリースされています。 Djangoのalphaリリースは、まだ開発中の扱いです。alpha, beta, rc, stable(=無印) の順でだいたいリリースされます。

Django 5.2 alpha 1 released | Weblog | Django

Django 5.2のリリースノートを見ると、 "Composite Primary Keys" (複合主キー)というのがあり、オッ、と思ったので試していきたいと思います。

Django 5.2 release notes - UNDER DEVELOPMENT | Django documentation | Django

ドキュメントにトピックとしてページが追加されています。

Composite primary keys | Django documentation | Django

これまでDjangoのORMは複合主キーをサポートしていなかったため、単一の主キー用のフィールドを追加するとか、ワークアラウンドで何とかやってきましたが、対応が追加されるのはうれしいですね。

試した環境は Python 3.13, Django 5.2a1, MySQL 8.0 です。

Django 5.2a1はpipでPyPIからインストールできます。

(venv)$ pip install Django==5.2a1

複合主キーを持つモデルを作成

ドキュメントに記載のあった Product, Order, OrderLineItem で試してみます。

myapp/models.py:

from django.db import models

class Product(models.Model):
    """製品"""
    name = models.CharField("名称", max_length=100)


class Order(models.Model):
    """注文"""
    reference = models.CharField("注文番号", max_length=20, primary_key=True)


class OrderLineItem(models.Model):
    """注文明細"""
    pk = models.CompositePrimaryKey("product_id", "order_id")
    product = models.ForeignKey(Product, verbose_name="製品", on_delete=models.CASCADE)
    order = models.ForeignKey(Order, verbose_name="注文", on_delete=models.CASCADE)
    quantity = models.IntegerField(verbose_name="数量")

スキーマを確認

makemigrations コマンドで 0001_initial.pyマイグレーションファイルを作成後、 sqlmigrate コマンドでcreate tableの内容を見てみます。

$ python manage.py sqlmigrate myapp 0001
--
-- Create model Order
--
CREATE TABLE `myapp_order` (`reference` varchar(20) NOT NULL PRIMARY KEY);
--
-- Create model Product
--
CREATE TABLE `myapp_product` (`id` bigint AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(100) NOT NULL);
--
-- Create model OrderLineItem
--
CREATE TABLE `myapp_orderlineitem` (`quantity` integer NOT NULL, `order_id` varchar(20) NOT NULL, `product_id` bigint NOT NULL, PRIMARY KEY (`product_id`, `order_id`));
ALTER TABLE `myapp_orderlineitem` ADD CONSTRAINT `myapp_orderlineitem_order_id_8feda485_fk_myapp_order_reference` FOREIGN KEY (`order_id`) REFERENCES `myapp_order` (`reference`);
ALTER TABLE `myapp_orderlineitem` ADD CONSTRAINT `myapp_orderlineitem_product_id_f0c8cf26_fk_myapp_product_id` FOREIGN KEY (`product_id`) REFERENCES `myapp_product` (`id`);

myapp_orderlineitem テーブルは複合主キーで作られるようになっていますね。 migrate コマンドでテーブル作成後にMySQLのほうでも describe コマンドでスキーマ定義を見てみましょう。

mysql> describe myapp_orderlineitem;
+------------+-------------+------+-----+---------+-------+
| Field      | Type        | Null | Key | Default | Extra |
+------------+-------------+------+-----+---------+-------+
| quantity   | int         | NO   |     | NULL    |       |
| order_id   | varchar(20) | NO   | PRI | NULL    |       |
| product_id | bigint      | NO   | PRI | NULL    |       |
+------------+-------------+------+-----+---------+-------+
3 rows in set (0.00 sec)

order_idproduct_id が主キーになっています。

ORMを試す

ドキュメントにあるサンプルコードをDjango shellで試してみます。

Django 5.2からは、Django shellを起動する際にモデルが自動インポートされた状態になります。 -v 2 オプションを指定すると、インポートされたモデルの詳細が表示されます。 django-extensionsの shell_plus にあった機能ですね。

$ python manage.py shell -v 2
9 objects imported automatically, including:

  from myapp.models import OrderLineItem, Order, Product
  from django.contrib.sessions.models import Session
  from django.contrib.contenttypes.models import ContentType
  from django.contrib.auth.models import User, Group, Permission
  from django.contrib.admin.models import LogEntry

Python 3.13.1 (main, Dec  4 2024, 08:54:14) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> Order
<class 'myapp.models.Order'>

ProductとOrderを1件作成し、作成したproductとorderを指定したOrderLineItemを1件作成します。

>>> product = Product.objects.create(name="apple")
>>> order = Order.objects.create(reference="A755H")
>>> item = OrderLineItem.objects.create(product=product, order=order, quantity=1)
>>> item.pk
(1, 'A755H')

複合主キーの pk はタプルになりました。

ドキュメント通りですが、 pk 引数をタプルで指定することもできるようです。

>>> item = OrderLineItem(pk=(2, "B142C"))
>>> item.pk
(2, 'B142C')
>>> item.product_id
2
>>> item.order_id
'B142C'

filterメソッドにpkを指定する際もタプルでの指定となるようです。

>>> OrderLineItem.objects.filter(pk=(1, "A755H"))
<QuerySet [<OrderLineItem: OrderLineItem object ((1, 'A755H'))>]>

ORMを試すのはこのぐらいにします。

Django admin

現時点では複合主キーのモデルは Django admin には非対応とドキュメントに記載があります。

試してみます。

myapp/admin.py:

from django.contrib import admin

from .models import Product, Order, OrderLineItem

admin.site.register(Product)
admin.site.register(Order)
admin.site.register(OrderLineItem)

checkを実行してみると、エラーになりました。

$ python manage.py check
# ... 中略
    admin.site.register(OrderLineItem)
    ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
  File "/home/tokibito/sample_nullpobug/django/composite_pk/venv/lib/python3.13/site-packages/django/contrib/admin/sites.py", line 117, in register
    raise ImproperlyConfigured(
    ...<2 lines>...
    )
django.core.exceptions.ImproperlyConfigured: The model OrderLineItem has a composite primary key, so it cannot be registered with admin.

やはりダメなようです。

admin組み込みのワークアラウンドチャレンジ

ここからはワークアラウンドに少しチャレンジしてみます。

正式対応か、サードパーティのモジュールなどが出るのを待ったほうがよいですが、無理やりadminに組み込みを試します。

本番環境で使うべきではないコードです。参考程度に。

model._meta.is_composite_pk をチェックしてエラーを出しているので、無理やり外してみます。 is_composite_pk プロパティはreadonlyなので、クラス側から無理やり書き換えます。

myapp/admin.py(抜粋):

OrderLineItem._meta.__class__.is_composite_pk = False
admin.site.register(OrderLineItem)
OrderLineItem._meta.__class__.is_composite_pk = True

これで runserver はエラーがでずに起動できます。

一覧まではいけました。しかし、詳細ページはURLがタプルを文字列化したものになってしまっていたりして動かないです。

この辺はModelAdminやChangeListクラスをカスタマイズする必要がありそうですね。手間がかかるのでここまでにしておきます。

感想

業務システムなどで複合主キーが使われることがありますが、Djangoでは今まで扱づらかったです。

今回のDjangoの標準機能として複合主キーがサポートされるのはいいですね。

ドキュメントにはmodels.ForeignObjectを使って複合主キーに対してリレーションを設定する方法も記載されていました。

Django adminの対応は今後期待したいです。