DjangoのORMの更新処理用にbulk_update()というメソッドが用意されている。 これは基本的にはSQLのUPDATE文を発行するのだが、通常とは違いCASE、WHEN、 THENを用いて複数の種類の値に対して1度UPDATEで更新できるようなSQLを発行 する。例えば次のようなSQLとなる。

UPDATE `fruits` SET
  `kind` = CASE
     WHEN (`fruits.id` = 1) THEN 'orange'
     WHEN (`fruits.id` = 2) THEN 'apple'
     WHEN (`fruits.id` = 3) THEN 'grape'
WHERE (`fruits`.`id` IN (1, 2, 3))

これは実際に発行されたSQLではないが、イメージとしては適当なものだろう。

Djangoのドキュメントにも記載1があるが、 ドキュメントからは詳しい挙動はわからなかった。 得にこのメソッドにはfieldsとbatch_sizeという引数を用意しており、 fieldsについては指定した属性のみを更新するように思えた。 しかし実際に発行されていたSQLを眺めていると、そうも思えないクエリを発行していた。 そのため今回はこのメソッドの挙動と実際に発行されるクエリについて調べることにした。

関連するパッケージのインストール

Pythonは既にインストールしていることを前提とする。今回使用したバージョンは以下となる。

python -V
Python 3.10.6

Djangoをインストールする。venvなどの構築は必要に応じて行ってほしいが、 ここでの趣旨とは外れるため省略する。バージョンは現時点での最新を使用した。

pip install django

今回はデータベースにMySQLを使用するため、mysqlclientもインストールする。

pip install mysqlclient

以下のバージョンを使用した。

pip list
Package     Version
----------- -------
asgiref     3.5.2
Django      4.1.1
mysqlclient 2.1.1
pip         22.2.2
setuptools  63.4.3
sqlparse    0.4.2

Djangoのテスト用プロジェクトの準備

Djangoのテスト用プロジェクトとしてexampleを作成する。

django-admin startproject example .

続いてテストのアプリケーションとしてappを作成する。

python manage.py startapp app

MySQLにテスト用のデータベースを作成する。

CREATE DATABASE example DEFAULT CHARACTER SET utf8mb4;

テスト用のデータベースへの接続の設定とアプリケーションの登録を行う。 example/settings.py(抜粋)::

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'app',
]


DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'example',
        'HOST': 'host.docker.internal',
        'PORT': 3306,
        'UER': 'root',
    }
}

そしてテスト用にFruitモデルを1つ定義する。

from django.db import models


class Fruit(models.Model):
    kind = models.CharField(max_length=10)
    number = models.IntegerField()

マイグレーションファイルを生成する。

python manage.py makemigrations

マイグレーションを実行し、テーブルを追加する。

python manage.py migrate

初期データを投入しておく

INSERT INTO `app_fruit` (`kind`, `number`) VALUES ('', 0), ('', 0), ('', 0);

データとしてこのようなデータが投入されたことを前提にする。

mysql> SELECT * FROM app_fruit;
SELECT * FROM app_fruit;
+----+------+--------+
| id | kind | number |
+----+------+--------+
|  1 |      |      0 |
|  2 |      |      0 |
|  3 |      |      0 |
+----+------+--------+
3 rows in set (0.00 sec)

これで準備は完了した。

bulk_updateが実行される際のSQLを確認する

Django Shellを用いて以下を実行する。

from app.models import Fruit

fruits = list(Fruit.objects.all())
fruits[0].kind = "orange"
fruits[0].number = 10

fruits[1].kind = "apple"
fruits[1].number = 20

fruits[2].kind = "grape"
fruits[2].number = 30

Fruit.objects.bulk_update(fruits, fields=["kind"])

最後のbulk_updateでは以下のSQLが発行される。

2022-09-10T11:37:39.194303Z	  134 Query	set autocommit=0
2022-09-10T11:37:39.197779Z	  134 Query	UPDATE `app_fruit` SET `kind` = CASE WHEN (`app_fruit`.`id` = 1) THEN 'orange' WHEN (`app_fruit`.`id` = 2) THEN 'apple' WHEN (`app_fruit`.`id` = 3) THEN 'grape' ELSE NULL END WHERE `app_fruit`.`id` IN (1, 2, 3)
2022-09-10T11:37:39.200108Z	  134 Query	commit
2022-09-10T11:37:39.204495Z	  134 Query	set autocommit=1

整形すると次のようになる。

UPDATE
    app_fruit
SET
    kind = CASE
        WHEN
            (app_fruit.id = 1) THEN 'orange'
        WHEN
            (app_fruit.id = 2) THEN 'apple'
        WHEN (app_fruit.id = 3) THEN 'grape'
    END
WHERE app_fruit.id IN (1, 2, 3)

データは次のように更新された。

mysql> SELECT * FROM app_fruit;
SELECT * FROM app_fruit;
+----+--------+--------+
| id | kind   | number |
+----+--------+--------+
|  1 | orange |      0 |
|  2 | apple  |      0 |
|  3 | grape  |      0 |
+----+--------+--------+
3 rows in set (0.00 sec)

期待値どおりfieldで指定した値のみ更新されることが確認できた。

おわりに

当初、挙動がおかしいと感じたものについては、 blk_update()の問題ではなく、別の要因で発生していたものだった。 実際に動かしてみて確認することで、自分の認識違いに気付けた。

脚注


1

Djangoのドキュメントのbulk_update()についての説明。https://docs.djangoproject.com/en/4.1/ref/models/querysets/#bulk-update