🔖

[Django]文字列のConcatを関数インデックスにする方法

2021/12/22に公開

はじめに

これは Django Advent Calendar 2021 22日目の記事です(空いていたので飛び入りで)。

Djangoでは3.2から、関数インデックスを使うことができます。この関数インデックスは便利ですが、PostgreSQLの仕様によって、Concatを使うことができません。この回避方法を記載します。

次のようなモデルを考えます。

class Device(models.Model):
    name = CharField(max_length=100, verbose_name="デバイス名")
    version = CharField(max_length=100, verbose_name="バージョン")

    class Meta:
        verbose_name = verbose_name_plural = "デバイス"

これに対して、nameとversionを結合したものでインデックスを作成したい場合(そんな必要性はないんですが)、次のようにindexesを定義します。

class Device(models.Model):
    class Meta:
        indexes = [Index(Concat("name", "version"), name="name_version")]

これは makemigrations でマイグレーションファイルが作成でき、SQLiteでは適用できます。しかしPostgreSQLに適用しようとすると次のエラーが出ます。「インデックスに使用する関数は不変でないといけない」というエラーのようです。

django.db.utils.ProgrammingError: functions in index expression must be marked IMMUTABLE

PostgreSQLのconcatはなぜ不変なのかについてはちゃんとした資料は見つかりませんでした。ググるとどうやら、タイムゾーンによって変わることがあるみたいな話が見つかりました。

https://www.postgresql.org/message-id/3361.1410026366%40sss.pgh.pa.us

とはいえ、少なくとも文字列の結合くらいはなんとかしたいです。

|| をつかった結合

concatは使えませんが、代わりに name || version とすることで、concatのように結合できます。これをインデックスとするためには次のようにします。

まず、Funcクラスを継承したクラスを用意します(正確には __rand____ror__が必要ですが)。 function は関数名ですが使用しないため空、 arg_joiner は引数を結合するもので、これを || にしています。

from django.db.models import Func

class ConcatOp(Func):
    function = ""
    arg_joiner = " || "

そしてインデックスを次のように定義します。

class Device(models.Model):
    class Meta:
        indexes = [Index(ConcatOp("name", "version"), name="name_version")]

これでマイグレーションファイルを作成して sqlmigrate を実行すると、次のようなSQLが出来上がります。

BEGIN;
--
-- Create index name_version on ConcatOp(F(name) || F(version)) on model device
--
CREATE INDEX "name_version" ON "core_device" ((("name" || "version")));
COMMIT;

これはちゃんとマイグレーションが適用できます。そして次のように実行計画を取得すると、インデックスが使われていることが確認できます(レコード数が少ないと使われないようですが)。

explain
select * from core_device where (((name || version))) = 'iPhone XR15.2';
Index Scan using name_version on core_device  (cost=0.15..8.17 rows=1 width=19)
  Index Cond: (((name)::text || (version)::text) = 'iPhone XR15.2'::text)

ただし1つ注意点があります。それは、 concat|| は同じではないからです。具体的には NULL があると、concatは無視しますが、 ||NULL になります。

select concat('a', null);  -- 'a'になる
select 'a' || null;  -- nullになる

CharFieldで null=True とするのは稀ですが、必要な場合は coalesce 関数を使ってください。

おわりに

データベースのチューニングの基本はインデックス作成ですが、関数インデックスが使えることで応用範囲が広がりました。なかなか楽しいので試してみてください。

Discussion