続・Django4.1からbulkのupsert的な処理ができるようになっていた〜なぜbulk_createに引数が追加されていたのか〜

この記事はDjangoもくもく会: 2回目 で書いた記事です。

Django4.1からbulkのupsert的な処理ができるようになっていたという記事を先週書いた。

そして最後に以下のコメントを書いた。

bulk_createに引数を追加して本機能を実装するのではなく、bulk_upsertやbulk_update_or_createがあっても良いのでは?と言う感想を持ったので、なぜbulk_createに作成されたのか、経緯をちょっと調べてみようと思った。

すると某先輩からの後押しもあり、続けてブログを書くことにした。

自分が調べた上での結論

想像になってしまうが、Issue・コードを見た上での自分の考えの結論。

何か詳しい方がいたら教えてください。

  • 最初からbulk_createに追加する前提でチケットが切られていた
  • SQLの構文的にINSERT INTO~から続くので、INSERT文を発行する側に追加する方が自然な感じがした
  • PostgreSQLだけど)ON CONFLICT DO NOTHINGを追加するignore_conflicts_suffix_sqlが当時存在していたので、なおさら追加した方が良さそうだった

起点となるチケット・プルリクエス

Support updating conflicts with QuerySet.bulk_create(). で起票されていた。

ここでは以下のように起票されており、bulk_createに引数を追加する前提で書かれていた。

It would be useful having a parameter in bulk_create(), like bulk_create(objs, upsert=True) or bulk_create(objs, update_conflicts=True), that lets you update existing rows when there's a conflict, much like what you can already do for a single entity with update_or_create().

bulk_createに対して主にupsert機能を実装したチケットはFixed #31685 -- Added support for updating conflicts to QuerySet.bulk_create(). #13065 のようだった。

実際にコードを見てみる

まず、bulk_createに引数が追加されている。

def bulk_create(self, objs, batch_size=None, ignore_conflicts=False):

だったのが、以下のようになっていた。

def bulk_create(
        self, objs, batch_size=None, ignore_conflicts=False,
        update_conflicts=False, update_fields=None, unique_fields=None,
    ):

https://github.com/django/django/pull/13065/files#diff-d58ef61559dc7af5fdf7b56fee13571a4d2948e784cd608f6afeacf3ac2fb195

その後、on_conflictという変数が増えていた。

これは_check_bulk_create_optionsというメソッドの返り値を保持してコンフリクトした時の設定を持つようだった。

on_conflict = self._check_bulk_create_options(
            ignore_conflicts,
            update_conflicts,
            update_fields,
            unique_fields,
        )

https://github.com/django/django/pull/13065/files#diff-d58ef61559dc7af5fdf7b56fee13571a4d2948e784cd608f6afeacf3ac2fb195R562

_check_bulk_create_optionsではバリデーションチェックをした後、Enumを利用したOnConflict.UPDATE または None を返却していた。

その後はトランザクションを張ってSQLを実際に実行しているようだった。

https://github.com/django/django/blob/stable/4.1.x/django/db/models/query.py#L796

他のポイント

元々、ignore_conflicts_suffix_sqlというメソッドが存在していたようだった。

django/db/backends/base/operations.py

変更前

def ignore_conflicts_suffix_sql(self, ignore_conflicts=None):
        return ''

変更後

def on_conflict_suffix_sql(self, fields, on_conflict, update_fields, unique_fields):
        return ''

ignore_conflicts_suffix_sqlのメソッド名がon_conflict_suffix_sqlとなっており、もう少し振る舞いが「広がった」ことがわかる。

こちらは親クラス側なので、特にロジックは書かれていない。

django/db/backends/postgresql/operations.py

そして実際にPostgreSQLSQLを組み立てるoperations.py側のコードが変わっていた。

変更前

def ignore_conflicts_suffix_sql(self, ignore_conflicts=None):
        return 'ON CONFLICT DO NOTHING' if ignore_conflicts else super().ignore_conflicts_suffix_sql(ignore_conflicts)

変更後

def on_conflict_suffix_sql(self, fields, on_conflict, update_fields, unique_fields):
        if on_conflict == OnConflict.IGNORE:
            return 'ON CONFLICT DO NOTHING'
        if on_conflict == OnConflict.UPDATE:
            return 'ON CONFLICT(%s) DO UPDATE SET %s' % (
                ', '.join(map(self.quote_name, unique_fields)),
                ', '.join([
                    f'{field} = EXCLUDED.{field}'
                    for field in map(self.quote_name, update_fields)
                ]),
            )
        return super().on_conflict_suffix_sql(
            fields, on_conflict, update_fields, unique_fields,
        )

上記のコードを見ると、「わざわざupsertを作るより、この既存ロジックに追加した方が自然かな」という気持ちになった。

また、SQL的にも INSERT INTO~ の後にコンフリクトした場合の記述を行うので、UPDATE側には書かないだろうな、という風にも思った。