Elixir
Phoenix
ecto

Ectoで個別テーブルによるpolymorphicをしてみる

はじめに

PhoenixとEcto、とても便利ですよね。ふとEctoのドキュメントを読んでいたらこんなことが書かれていました。belongs_toのあたりです。
https://hexdocs.pm/ecto/Ecto.Schema.html#belongs_to/3-polymorphic-associations

どんなことが書かれているのか、疑問に思ったこと、試してみたこと、その所感で構成されています。

対訳

まずは訳してみます。章チャプターっぽいのは勝手につけてます。

ポリモーフィズム

One common use case for belongs to associations is to handle polymorphism. For example, imagine you have defined a Comment schema and you wish to use it for commenting on both tasks and posts.

belongs_toのよくある用途の1つはポリモーフィック関連を扱いたいときでしょう。例えば、Commentスキーマを定義したとして、それをTaskPostの両方のコメント投稿に利用したいとしましょう。

Some abstractions would force you to define some sort of polymorphic association with two fields in your database:

ある類の抽象化では(※訳注:おそらくRailsを意識している)次の2つのフィールドをDBに定義することでポリモーフィック関連を定義することを強制します:

* commentable_type
* commentable_id

The problem with this approach is that it breaks references in the database. You can’t use foreign keys and it is very inefficient, both in terms of query time and storage.
In Ecto, we have three ways to solve this issue.

この方法の問題点は、データベースレベルでの関連が作れないことです。外部キーも使えませんし、クエリ実効速度の観点からも容量の観点からも非効率です。
Ectoではこれを解決するために3つの方法があります。

① 外部キーを並べる

The simplest is to define multiple fields in the Comment schema, one for each association:

最もシンプルなのはCommentスキーマに複数の関連用のフィールドを関連別に用意することです:

* task_id
* post_id

Unless you have dozens of columns, this is simpler for the developer, more DB friendly and more efficient in all aspects.

手で数えられる程度のカラム数であればこの方法は開発者に優しく、DBとの親和性が高く、あらゆる面からより効率的です。

② 個別テーブル

Alternatively, because Ecto does not tie a schema to a given table, we can use separate tables for each association. Let’s start over and define a new Comment schema:

EctoはスキーマとDBのテーブルを(直接)結び付けないことから、関連毎に違うテーブルを使用するという方法を取ることができます。ここで新しいCommentスキーマを定義することから初めてみましょう:

defmodule Comment do
  use Ecto.Schema

  schema "abstract table: comments" do
    # This will be used by associations on each "concrete" table
    field :assoc_id, :integer
  end
end

Notice we have changed the table name to “abstract table: comments”. You can choose whatever name you want, the point here is that this particular table will never exist.

テーブルの名前を“abstract table: comments”に変更したことに注目してください。ここには(それがテーブル名として存在しない限り)どんな文字列を入れても構いません。要は架空のテーブルを指定することになります。

Now in your Post and Task schemas:

Post Taskのスキーマは次のようになります:

defmodule Post do
  use Ecto.Schema

  schema "posts" do
    has_many :comments, {"posts_comments", Comment}, foreign_key: :assoc_id
  end
end

defmodule Task do
  use Ecto.Schema

  schema "tasks" do
    has_many :comments, {"tasks_comments", Comment}, foreign_key: :assoc_id
  end
end

Now each association uses its own specific table, “posts_comments” and “tasks_comments”, which must be created on migrations. The advantage of this approach is that we never store unrelated data together, also ensuring we keep database references fast and correct.

これなら各関連はそれぞれ("posts_comments" "tasks_comments"という)独自のテーブルを使うことになるので、もちろん個別にマイグレーションされなければなりません。この方法の利点は互いに関係のないデータを一緒にストアしないということと、速くて正確な参照ができるということです。

When using this technique, the only limitation is that you cannot build comments directly. For example, the command below

この方法を使った場合の制限は、コメントを直接作ることができないということのみです。例えば

Repo.insert!(%Comment{})

will attempt to use the abstract table. Instead, one should use

このコードは抽象(存在しない)テーブルを利用しようとします。代わりに、

Repo.insert!(build_assoc(post, :comments))

where build_assoc/3 is defined in Ecto. You can also use assoc/2 in both Ecto and in the query syntax to easily retrieve associated comments to a given post or task:

こちらを使うべきでしょう。build_assoc/3Ectoに定義されています。PostTaskに紐付いたコメントを取得するにはEctoモジュールか query syntax に存在する assoc/2 を利用できます:

# Fetch all comments associated with the given task
Repo.all(assoc(task, :comments))

#Or all comments in a given table:
Repo.all from(c in {"posts_comments", Comment}), ...)

many_to_manyを使う

The third and final option is to use many_to_many/3 to define the relationships between the resources. In this case, the comments table won’t have the foreign key, instead there is a intermediary table responsible for associating the entries:

3つ目の手段はmany_to_many/3を用いてリソース同士の関連を定義する方法です。この場合commentsテーブルは外部キーを持たず、新たに中間テーブルがPostCommentTaskComment間の関連付けに責任を持ちます:

defmodule Comment do
  use Ecto.Schema
  schema "comments" do
    # ...
  end
end

defmodule Post do
  use Ecto.Schema

  schema "posts" do
    many_to_many :comments, Comment, join_through: "posts_comments"
  end
end

defmodule Task do
  use Ecto.Schema

  schema "tasks" do
    many_to_many :comments, Comment, join_through: "tasks_comments"
  end
end

See many_to_many/3 for more information on this particular approach.

詳しくは many_to_many/3 を参照してください。

(以上)

疑問

ポリモーフィック関連って、そもそもどうして必要なのでしょう。例えばこちらの記事を参考にしてみます。
Railsのポリモーフィック関連とはなんなのか | Qiita

読んでいただくと、まさに上の訳に書いてある内容にあることがアンチパターンだといわれてる!!という状態にあることに気づきます。①はダックタイピングのNG例として挙げられているレベルです。(Phoenixでもそのように使うかはともかく…)
さらに個人的には①はxxx_idnilになる可能性があるのも好きじゃないポイントです。NOT NULLできません。その点では①を改善したのが③になると考えています。

個人的に疑問が残ったのは②でした。一体これはどういう実装・動作になるのでしょう。先のリンクでは

ただ、EmployerMessageとEmployeeMessageが同じインターフェースを持つのならば、この方法では同じコードを繰り返し書くことになるのであまり好ましくありません。
また、メッセージ送信者モデルが増えれば増えるほどコードの重複も増え、複雑度もそれに応じて高くなることが予想されます。

と書かれていた手法に似ています。DRYじゃないってことですね。
なぜこの方法がドキュメントで紹介されているのでしょうか。

作ってみた

ドキュメントも不完全だし自分で実装してみたほうが速い!というわけでサンプルアプリケーションを作ってみました。
> ndac-todoroki/ecto_abstract_table_test <

まぁコード見てもらったほうが速いと思うんであんまり説明することもないですが、実はこの②の方法、比較的Railsのポリモーフィックに近いものであることがわかります。
特徴を整理すると、

  • コメント用テーブルはコメント先の数だけ必要
  • スキーマは1つ
  • 関数のたぐいも一箇所に集められる

ということになります。つまりアプリケーション側での名前とDBでの名前との関連が密ではないので、ロジック部分だけポリモーフィックな感じにすることができる、というわけですね。DBレベルでは完全に独立したテーブルなので速度もNOT NULLも犠牲にならないし、(今回のサンプルで言えば)BlogBlog.Comment モジュールあたりで抽象化を完結させれば外側から何に対するコメントであるかを気にする必要もほぼ無いわけです。

ただ、EmployerMessageとEmployeeMessageが同じインターフェースを持つのならば、この方法では同じコードを繰り返し書くことになるのであまり好ましくありません。
また、メッセージ送信者モデルが増えれば増えるほどコードの重複も増え、複雑度もそれに応じて高くなることが予想されます。

このあたりも抽象モジュールを用意したことで解決していることがわかります。そのモジュールに共通の動作を書いていくだけで良いことになります。

注意点

ただし気にしなくてはいけないことがあります。

1.(種類を問わず)コメントを新しい順に取得、などの用途はJOINが必要

これは当たり前なんですが、commentsテーブルなるものは存在しないので Comment |> Repo.all みたいなことはできません。ちなみにサンプルではそういう実装をしてありますのでエラーが出ます(READMEからコピペ)

iex(5)> Blog.list_comments

01:00:05.942 [debug] QUERY ERROR source="abstract table: comments" db=10.5ms queue=0.2ms
SELECT a0."id", a0."assoc_id", a0."inserted_at", a0."updated_at" FROM "abstract table: comments" AS a0 []
** (Postgrex.Error) ERROR 42P01 (undefined_table): relation "abstract table: comments" does not exist
    (ecto) lib/ecto/adapters/sql.ex:431: Ecto.Adapters.SQL.execute_and_cache/7
    (ecto) lib/ecto/repo/queryable.ex:133: Ecto.Repo.Queryable.execute/5
    (ecto) lib/ecto/repo/queryable.ex:37: Ecto.Repo.Queryable.all/4

Railsの説明のリンクの方から引用しますが、

もしポリモーフィックを使わずにやるならば、以下のようにEmployerMessageモデル(employer_messagesテーブル)とEmployeeMessageモデル(employee_messagesテーブル)を作り、thread_idのような両テーブルに共通するカラムを用意してJOINする、などという方法が考えられます。

みたいなことを実装する必要があるでしょう。ただしこれも Blog.Comment の中に隠してしまえば表からは特に気にする必要もありません。

2. カラムの追加削除をする場合は工夫したほうが良さげ

例えばたくさん commentable なテーブルができて、それぞれにたいして hoge_comments なテーブルを作成していくと、ここのレベルでのコードの管理が大変になります。migrationファイルが増えるとDSLだし読みにくいです。
この場合は、例えば「コメントにトリップを追加する」のであれば1つのマイグレーションファイルですべてのコメント系テーブルをアップデートするようにしたほうが後から読み返した開発者に優しくなります。

あるいはマイグレーションファイルもexsですから、それ用にマクロを作っちゃっても良い…のかもしれませんね。

おわりに

目的別にDBどんどん分けちゃえばいいじゃん!って話はElixir forumとかででてますね。ポリモーフィズムだけではなく、DDD的なサービスを分割して組み上げる際にもそのドメインに必要な最低限のデータをDBに保存してしまう、同系列のデータベースはPhoenix.PubSubで同期する、関連はシンプルにする、みたいなやり方はElixirがオブジェクトを扱わず高速にDBの読み書きができる恩恵でもあるのかもしれません。

今回はちょっと実験したのでシェアしてみました。何かお気づきの点ありましてもなくてもコメントで指摘していただければと思います。あと仕事もいただければ