« ^ »

DjangoでTiDBを使うために立ちはだかる壁をむりやり突破する

所要時間: 約 3分

ひょんなことからTiDBを使用する機会に出会った。そしてDjangoからTiDBを利用したかった。DjangoにはデータベースバックエンドとしてMySQLを標準で選択できる。そしてTiDBはNewSQLでMySQLと互換がある。そうであれば躓くことなくDjango+TiDBを構築できるかとも思える。しかし実際にやってみると、そんなに上手くはいかない。現実というものはいつでも厳しいのだ。今回はそんな現実と向き合い、Django+TiDB構成を利用できる ようにするまでに躓き、試行錯誤した内容を書くことにした。

ALTER TABLEと同時にUNIQUE制約を追加するような操作をサポートしていない

現在のTiDBではALTER TABLEと同時にUNIQUE制約を追加するような操作をサポートしていない。それを実現するためのissue及びプルリクエストが提出されている1が、現在目下作業中といったところだ。この操作は多くのO/Rマッパーで使用されているため、サポートすることが待たれているようだ。

簡易的に複数のSQLを実行することで制限を突破する

ALTER TABLEと同時にUNIQUE制約を追加できないのであれば、複数のSQLを実行しカラムを追加した後、UNIQUE制約を追加すれば良い。そのためにはDjangoのマイグレーション時のSQLを生成しているDatabaseSchemaEditorを書き換える必要がある。 django_tidbはtidb用にdjango_tidb.schema.DatabaseSchemaEditorを実装している。このクラスはMysqlDatabaseSchemaEditorを継承している。このクラスを書き換えることで実現する。

カラム追加のためのSQLを発行しているメソッドはadd_field()であるため、これをそのまま親クラスであるMysqlDatabaseSchemaEditorの、更なる親クラスdjango.db.base.schema.BaseDatabaseSchemaEditorからコピーし、次のように変更を追加した。FIXFIXFIXで囲まれている2箇所が修正したところだ。

    def add_field(self, model, field):
        """
        Create a field on a model. Usually involves adding a column, but may
        involve adding a table instead (for M2M fields).
        """
        # Special-case implicit M2M tables
        if field.many_to_many and field.remote_field.through._meta.auto_created:
            return self.create_model(field.remote_field.through)
        # Get the column's definition
        definition, params = self.column_sql(model, field, include_default=True)
        # It might not actually have a column behind it
        if definition is None:
            return
        # Check constraints can go on the column SQL here
        db_params = field.db_parameters(connection=self.connection)
        if db_params["check"]:
            definition += " " + self.sql_check_constraint % db_params
        if (
            field.remote_field
            and self.connection.features.supports_foreign_keys
            and field.db_constraint
        ):
            constraint_suffix = "_fk_%(to_table)s_%(to_column)s"
            # Add FK constraint inline, if supported.
            if self.sql_create_column_inline_fk:
                to_table = field.remote_field.model._meta.db_table
                to_column = field.remote_field.model._meta.get_field(
                    field.remote_field.field_name
                ).column
                namespace, _ = split_identifier(model._meta.db_table)
                definition += " " + self.sql_create_column_inline_fk % {
                    "name": self._fk_constraint_name(model, field, constraint_suffix),
                    "namespace": "%s." % self.quote_name(namespace)
                    if namespace
                    else "",
                    "column": self.quote_name(field.column),
                    "to_table": self.quote_name(to_table),
                    "to_column": self.quote_name(to_column),
                    "deferrable": self.connection.ops.deferrable_sql(),
                }
            # Otherwise, add FK constraints later.
            else:
                self.deferred_sql.append(
                    self._create_fk_sql(model, field, constraint_suffix)
                )

        # FIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIX
        is_unique_constraint = "UNIQUE" in definition
        if is_unique_constraint:
            definition = definition.replace("UNIQUE", "")
        # FIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIX
        
        # Build the SQL and run it
        sql = self.sql_create_column % {
            "table": self.quote_name(model._meta.db_table),
            "column": self.quote_name(field.column),
            "definition": definition,
        }
        self.execute(sql, params)

        # FIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIX
        if is_unique_constraint:
            self.execute(self._create_unique_sql(model, [field.column]), params)
        # FIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIXFIX

        # Drop the default if we need to
        # (Django usually does not use in-database defaults)
        if (
            not self.skip_default_on_alter(field)
            and self.effective_default(field) is not None
        ):
            changes_sql, params = self._alter_column_default_sql(
                model, None, field, drop=True
            )
            sql = self.sql_alter_column % {
                "table": self.quote_name(model._meta.db_table),
                "changes": changes_sql,
            }
            self.execute(sql, params)
        # Add an index, if required
        self.deferred_sql.extend(self._field_indexes_sql(model, field))
        # Reset connection if required
        if self.connection.features.connection_persists_old_columns:
            self.connection.close()

このスキーマエディタを使用することでエラーは発生しなくなった。

TEXT/BLOB/JSON型のカラムにデフォルトの値を設定できない

空文字であれば出来る。それ以外はできない。仕方がないのでSchemaEditrに次のコードを追加した。

        is_contain_default_statement = 'DEFAULT (%s)' in definition
        if is_contain_default_statement:
            definition = definition.replace('DEFAULT (%s)', '')
            if len(params) > 1:
                raise ValueError("Cannot detected collect migration")
            params = []

以下は検証用のメモ。

set sql_mode='';
CREATE TABLE test_text_default_json(c1 JSON NOT NULL DEFAULT '');

CREATE TABLE test_text_default_json2(c1 JSON NULL DEFAULT '');

ALTER TABLE `test_text_default_json` MODIFY COLUMN `c1` text DEFAULT '';

DROP TABLE test_text_default_json;

DROP TABLE test_text_default_json2;