Djangoのマイグレーションファイルを効率良くスカッシュする

Djangoはデータベースのスキーマ更新のために、マイグレーションファイルを作成し管理する。 manage.pymakemigrations というサブコマンドを提供しており、マイグレーションファイルとモデル定義を元にスキーマの変更を検知し、必要なマイグレーションファイルを生成する。開発中にモデル定義を変更した場合、次のコマンドでマイグレーションファイルを生成し、それを適応することで、データベースのスキーマ変更を行う。

  1. python manage.py makemigrations
  2. python manage.py migrate

複数のトピックの開発が平行して進む場合、このマイグレーションファイルが、ブランチによって分岐してしまうことがある。例えばログイン機能とカート機能を実装しているEコマースサイトを考える。ログイン機能とカート機能はそれぞれ異なるブランチで開発しており、それぞれにモデル定義の更新が必要になった。ログイン機能ブランチとカート機能ブランチは、それぞれにマイグレーションファイルを生成した。Djangoのマイグレーションファイルには依存関係が記述されている。その依存関係には他のDjangoアプリケーションのマイグレーションも記述されるため、他のDjangoアプリケーションに依存するマイグレーションファイルが増えると、依存関係も複雑になる。


Main Branch
     |
     |
+----+----+
| Commit  |
+----+----+
     |
     +-----------------+-------------------+
     |                 |                   |
+----+----+            |Login              |Cart
| Commit  |            |Branch             |Banch
+----+----+            |                   |
     |                 |                   |
                  +----+----+         +----+----+
                  | Commit  |         | Commit  |<- Add
                  +----+----+         +----+----+   a migration
                       |                   |
                       |                   |
                  +----+----+
                  | Commit  |<- Add
                  +---------+   a migration
                       |
                       |
                  +----+----+
                  | Commit  |<- Add
                  +---------+   a migration
                       |
                       |
                  +----+----+
                  | Commit  |<- Add
                  +---------+   a migration

Djangoはこういった問題のために squashmigrations を提供している。 squashmigrations は指定した複数のマイグレーションファイルを1つにまとめる。ただしスカッシュ対象のマイグレーションに依存するマイグレーションが他にあったとしても、スカッシュできるのであれば実行し、依存関係の設定を組替えることはしない。そのため依存関係グラフが破綻する事がある。大きなコードベースの場合、この辻褄合わせの作業が結構大変だ。そこでスカッシュ対象に依存しているマイグレーションがどこかに存在するのか、どこからどこまでをスカッシュするのか、スカッシュしたファイルの後始末などの操作を簡略化する事にした。

マイグレーションファイルはmigrationsディレクトリ配下に 0002_xxx.py のようなファイル名で保存されている。Emacsにはdiredという良いファイラがあるので、そこでスカッシュ対象のファイルを選択できるように拡張することにした。またスカッシュ対象マイグレーションが所属するDjangoアプリケーションに依存したマイグレーションファイルを検索するコマンドも実装した。

django-migration.el

;;; django-migration --- Support django migration. -*- lexical-binding: t -*-

;; Copyright (C) 2023 TakesxiSximada

;; Author: TakesxiSximada <[email protected]>
;; Maintainer: TakesxiSximada <[email protected]>
;; Version: 1
;; Package-Version: 20230121.0000
;; Package-Requires: ((emacs "28.1"))
;; Date: 2023-01-21

;; This file is part of django-migration.

;; django-migration is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Affero General Public License as
;; published by the Free Software Foundation, either version 3 of the
;; License, or (at your option) any later version.

;; django-migration is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
;; Affero General Public License for more details.

;; You should have received a copy of the GNU Affero General Public
;; License along with this program.  If not, see
;; <https://www.gnu.org/licenses/>.

;;; Code:
(require 'vterm)

(defcustom django-migration-squash-python-command "python" "")
(defcustom django-migration-module-directory "." "")
(defcustom django-migration-squash-database-name "testing" "")

(defvar django-migration-squash-app-migration-alist nil)
(defvar django-migration-squash-buffer-name "*DJANGO SQUASH*")
(defvar django-migration-dependencies-buffer-name "*DJANGO MIGRATION DEPS*")
(defvar django-migration-dependencies-buffer-name-temp "*DJANGO MIGRATION DEPS TEMP*")

(defun django-migration-squash-convert-to-app-migration-alist (files)
  (interactive)
  (let* ((last-file (car (seq-reverse files))))
    `((migration-begin . ,(if (eq 1 (seq-length files))
			      "" (car (seq-reverse (string-split (car files) "/")))))
      (migration-end . ,(car (seq-reverse (string-split last-file "/"))))
      (app-label . ,(nth 2 (seq-reverse (string-split last-file "/")))))))

;;;###autoload
(defun django-migration-create-database ()
  "Recreate database"
  (interactive)
  (sql-send-string
   (format "DROP DATABASE IF EXISTS %s;" django-migration-squash-database-name))

  (sql-send-string
   (format "CREATE DATABASE %s DEFAULT CHARACTER SET utf8mb4;" django-migration-squash-database-name)))

;;;###autoload
(defun django-migration-select ()
  "Select django migration files"
  (interactive)
  (setq django-migration-squash-app-migration-alist
	(django-migration-squash-convert-to-app-migration-alist
	 (dired-get-marked-files))))

;;;###autoload
(defun django-migration-squash ()
  "Squash django migration files"
  (interactive)
  (let ((vterm-shell (format "%s manage.py squashmigrations --noinput %s %s %s"
			     django-migration-squash-python-command
			     (cdr (assoc 'app-label django-migration-squash-app-migration-alist))
			     (file-name-base (cdr (assoc 'migration-begin django-migration-squash-app-migration-alist)))
			     (file-name-base (cdr (assoc 'migration-end django-migration-squash-app-migration-alist)))))
	(vterm-buffer-name django-migration-squash-buffer-name)
	(vterm-kill-buffer-on-exit nil))
    (vterm)))


;;;###autoload
(defun django-migration-delete-squash ()
  "Delete unnessesary squashed migration file

It need Django Extentions https://django-extensions.readthedocs.io/en/latest/index.html.
See https://django-extensions.readthedocs.io/en/latest/index.html.
"
  (interactive)
  (let ((vterm-shell (format "%s manage.py delete_squashed_migrations --noinput %s"
			     django-migration-squash-python-command
			     (cdr (assoc 'app-label django-migration-squash-app-migration-alist))))
	(vterm-buffer-name django-migration-squash-buffer-name)
	(vterm-kill-buffer-on-exit nil))
    (vterm)))

(defun django-migration-dependencies-format-buffer ()
  (interactive)
  (with-current-buffer (get-buffer-create django-migration-dependencies-buffer-name)
    (insert (with-current-buffer
		(get-buffer django-migration-dependencies-buffer-name-temp)
	      (buffer-substring-no-properties (point-min) (point-max))))
    (replace-regexp ".*\(\'" "" nil (point-min) (point-max))
    (sort-lines nil (point-min) (point-max))
    (delete-duplicate-lines (point-min) (point-max))))

;;;###autoload
(defun django-migration-dependencies ()
  (interactive)
  (let ((vterm-shell (format "grep \"('%s', '0\" %s/*/migrations/*.py"
			     (cdr (assoc 'app-label django-migration-squash-app-migration-alist))
			     django-migration-module-directory))
	(vterm-buffer-name django-migration-dependencies-buffer-name-temp)
	(vterm-kill-buffer-on-exit nil)
	(vterm-exit-functions nil))
    (vterm)))

(provide 'django-migration)
;;; django-migration.el ends here

diredmigrations ディレクトリを表示し m キーでスカッシュするマイグレーションファイルを選択する。 M-x django-migration-select を実行し、選択したマイグレーションファイルをスカッシュ対象として登録する。 M-x django-migration-dependencies を実行すると、スカッシュ対象のアプリケーションに依存するマイグレーションファイルの一覧を確認できる。依存されているマイグレーションを上手く避けつつ、 M-x django-migration-select を選択し直す。

スカッシュ対象を確定できたら、 M-x django-migration-squash を実行し、スカッシュを行う。これによりスカッシュされたマイグレーションファイルが生成される。ただし古いマイグレーションファイルは削除しない。もし django-extentions をインストールしていて、古いマイグレーションファイルを削除したい場合は、 M-x django-migration-delete-squash を実行することで、スカッシュにより必要のなくなったマイグレーションファイルを削除できる。