ポリモーフィック・アソシエーション

要約:ポリモーフィック・アソシエーションの代わりに、常に別々のテーブルを使用してください。

Railsでは、いわゆる「ポリモーフィックな関連付け」を定義することができます。これは通常、テーブルに2つのカラムを追加することで機能します: ターゲット型カラムとターゲットIDです。たとえば、この記事を書いている時点では、members

  • source_type使用するモデルを定義する文字列で、Project またはNamespace のいずれかです。
  • source_id: 検索する行のIDsource_typesource_type例えばsource_typesource_type Project の場合、source_id にはプロジェクト ID が含まれます。

このような設定は一見便利そうに見えますが、多くの欠点があります。

スペースの無駄

この設定は使用するモデルを決定するために文字列の値に依存しているため、多くのスペースを浪費します。例えば、ProjectNamespace 、最大サイズは9バイトで、PostgreSQLを使用する場合は文字列ごとに1バイト追加されます。これは1行あたり10バイトに過ぎないかもしれませんが、このような設定で十分なテーブルと行を使用すると、かなりのディスク容量とメモリ(インデックス用)を浪費することになります。

インデックス

アソシエーションは2つのカラムに分割されているため、クエリを効率的に実行するためには複合インデックスが必要になります。コンポジットインデックスは全く間違ってはいませんが、最適なパフォーマンスを確保するためには、これらのインデックスにおけるカラムの順序が重要であるため、セットアップが厄介になる可能性があります。

一貫性

多相アソシエーションで本当に大きな問題の1つは、外部キーを使用してデータベースレベルでデータの一貫性を強制できないことです。一貫性をデータベースレベルで強制するには、独自の外部キーロジックを書いて多相の関連付けをサポートしなければなりません。

データベースレベルで一貫性を強制することは、健全な環境をメンテナーするために絶対的に重要であり、ポリモーフィック関連付けを避けるもう1つの理由です。

クエリのオーバーヘッド

多相連想を使用する場合、常に両方の列を使用してフィルタリングする必要があります。例えば、次のようなクエリを書くことになるでしょう:

SELECT *
FROM members
WHERE source_type = 'Project'
AND source_id = 13083;

ここでPostgreSQLは、両方の列にインデックスが付けられていれば、非常に効率的にクエリを実行することができます。クエリがより複雑になると、これらのインデックスを効果的に使用できなくなる可能性があります。

混在した責務

関数やクラスと同様に、テーブルは単一の責任を持つべきです。多相の関連付けを使用する場合、同じテーブルに異なる種類のデータ (おそらく異なるカラムセット) を格納することになります。

解決策

幸いなことに、このような問題を解決する方法はあります。別のテーブルを使用することで、アプリケーションロジックを追加することなく、一貫性を確保し、効率的にデータをクエリするためにデータベースが提供するすべてのものを使用することができます。

例えば、members テーブルにプロジェクトとグループの承認者と保留者の両方を保存し、保留状態はカラムrequested_at が設定されているかどうかで判断するとします。スキーマ上、このような設定は、さまざまなカラムが特定の行にのみ設定され、スペースを浪費することにつながります。また、特定のインデックスが特定の行にのみ設定される可能性もあります。最後に、このようなテーブルへのクエリは、理想的なクエリではありません。例えば

SELECT *
FROM members
WHERE requested_at IS NULL
AND source_type = 'GroupMember'
AND source_id = 4

このようなテーブルは、別々のテーブルに分割する必要があります。例えば、この場合4つのテーブルを持つことになります:

  • project_members
  • group_members
  • pending_project_members
  • pending_group_members

これにより、データのクエリが簡単になります。例えば、あるグループのメンバーを取得するには、次のようにします:

SELECT *
FROM group_members
WHERE group_id = 4

グループの保留中のメンバーを順番に取得するには、次のように実行します:

SELECT *
FROM pending_group_members
WHERE group_id = 4

もし両方を取得したい場合は、UNION を使用することができます。ただし、SELECT でどのカラムを取得したいのかを明示する必要があります。例えば

SELECT id, 'Group' AS target_type, group_id AS target_id
FROM group_members

UNION ALL

SELECT id, 'Project' AS target_type, project_id AS target_id
FROM project_members

上記の例は少し馬鹿げているかもしれませんが、データをマージして同じページに表示することを妨げるものは何もないことを示しています。カラムを明示的に選択することで、データベースがデータを取得するために行う作業が少なくなるため、クエリを高速化することもできます(使用していないカラムも含めてすべてのカラムを選択する場合と比較して)。

スキーマも簡単になります。source_type カラムの保存とインデックスの両方が不要になり、外部キーを簡単に定義できるようになります。また、IS NULL 条件を使って行をフィルタリングする必要もなくなります。

まとめると、別々のテーブルを使うことで、外部キーを効果的に使用し、必要なところだけにインデックスを作成し、スペースを節約し、データをより効率的にクエリし、テーブルをより簡単に拡張することができます(たとえば、別々のディスクに格納するなど)。この副次的な効果として、1つのモデルで異なる種類のデータを扱う必要がなくなるため、コードが簡単になります。