はじめに
ERD を自動生成しようと思い rails-erd を触り始めたのですが、いい感じに関連付けなどを表現してくれるので、どのようにして関連付けを取得しているのかなー🧐と思い、調べてみた内容を記載しています。
と、本題に入る前に自分がすごいと思う rails-erd の特徴を述べさせてください🙇♂️
本題のスタートは こちら です。
私の考える rails-erd のすごい特徴3つ
1. バリデーションから必須の判定を行ってくれる
ERD から素早くドメインロジックを理解するには、エンティティ間の関係性が正確に記述されている必要があります。1対1なのか、1対多なのか、多対多なのかは必須の情報ですよね。そして、そこにプラスで「必須かどうか」の情報があると私としてはすごく理解が早まります。rails-erd は必須のバリデーション(presence: true
)を設定していれば、必須と判定してくれます!ありがたい。
ドキュメントにもしれっと以下のように記載されています。
properties are automatically determined based on your validators and non-nullable foreign keys.
訳)プロパティは、バリデータと NOT NULL な外部キーに基づいて自動的に決定されます。
2. 単一テーブル継承やポリモーフィックを理解できる
rails-erd では単純な関連付けだけではなく、単一テーブル継承やポリモーフィックも表現させることができます。ドメインロジックを理解する上で重要な情報なので、ERD から確認できると便利ですよね。スキーマ情報を解析して生成される ERD では表現するのが難しいですが、rails-erd はリフレクションと呼ばれるしくみを利用しているため、メタプログラミングで書かれたコードでも理解できるそうです。
Rails ERD uses reflection instead of static analysis, it even recognises meta-programmed associations.
訳)Rails ERDは静的分析の代わりにリフレクションを使用しており、メタプログラムされた関連付けも認識します。
3. カスタマイズが簡単らしい
Another goal of Rails ERD is to be so extensible that it's the last diagramming tool for Active Record models that you'll ever need. If the standard output is unsatisfying, it shouldn't be a reason to have to switch to a different tool.
訳)Rails ERDのもう一つの目標は、Active Record モデルのための最終的なダイアグラムツールとして選ばれるほど拡張性が高いことです。
Rails を使うユーザーが最終的に行き着くダイアグラムツールを目指しているということなのですが、なんと rails-erd は出力するダイアグラムツールを変更することが可能なんです。Customise ページ には、yuml というダイアグラムツールで出力する例が記載されています。もっと見やすくビジュアライズしてくれるツールがあれば、そちらに乗り換えるという選択ができるわけです。
ソースコードを読む
ここからが本題です。ソースコードを追っていきながら読み解いていきます。
代表的なクラスの紹介
最初に、rails-erd の代表的なクラスについて紹介します。
Class名 | 役割 |
---|---|
RailsERD::Domain | Rails のドメインモデルを表す。ここがモデル情報のスタート地点。 |
RailsERD::Domain::Entity | エンティティ。ActiveRecord のモデルを表す。 |
RailsERD::Domain::Attribute | エンティティの属性(カラム情報)を表す。 |
RailsERD::Domain::Relationship | エンティティ間の関連付けを表す。 |
RailsERD::Domain::Specialization | エンティティの特殊な関連付け(単一テーブル継承やポリモーフィックなど)を扱う。 |
RailsERD::Diagram | 図の作成を簡単にするための抽象クラス |
RailsERD::Diagram::Graphviz | Graphviz ベースの図を作成するためのクラス |
※ RailsERD::Diagram, RailsERD::Diagram::Graphviz クラスは、 RailsERD::Domain から取得される情報を元にダイアグラムを作成するクラスになるので、この記事ではこれ以上は触れません。
RailsERD::Domain クラス
RailsERD::Domain がドメインモデルのスタートです。以下のように扱うことができます。
require "rails_erd/domain"
# Rails のモデルやコントローラなどを全てロードする
Rails.application.eager_load!
domain = RailsERD::Domain.generate
# 全てのエンティティを返す
domain.entities
# エンティティの属性を返却する
domain.entities.first.attributes
# 全ての関連づけを返す
domain.relationships
# 全ての特殊情報を返す
domain.specializations
RailsERD::Domain.generate は何を行っているのか
class Domain
class << self
def generate(options = {})
new ActiveRecord::Base.descendants, options
end
end
def initialize(models = [], options = {})
@source_models, @options = models, RailsERD.options.merge(options)
end
end
ここでは ActiveRecord::Base.descendants
を @source_models
に格納して、インスタンスを返しています。descendants
は全ての下位クラスを返すメソッドなので、 ActiveRecord::Base.descendants
は全てのモデルを返します。実はこれがドメインモデル情報の大元になっています!
entities, relationships, specializations メソッドでは何をやっているのか
def entities
@entities ||= Entity.from_models(self, models)
end
def relationships
@relationships ||= Relationship.from_associations(self, associations)
end
def specializations
@specializations ||= Specialization.from_models(self, models)
end
Entity, Relationship, Specialization の各クラスからそれぞれ情報を取って来て返却しています。それぞれのクラスでどのように情報を取ってきているか見ていく必要がありますね。
引数に渡される models と associations
ここで、models
と associations
が新しく出てきたので、どのような内容が入っているのか確認します。
def models
@models ||= @source_models.select { |model| check_model_validity(model) }.reject { |model| check_habtm_model(model) }
end
def associations
@associations ||= models.collect(&:reflect_on_all_associations).flatten.select { |assoc| check_association_validity(assoc) }
end
models
は @source_models
から妥当なモデルのみを抽出して返却してます。詳細は割愛しますが、モデルに名前がついているか、has_and_belongs_to_many の関連性などで選別しているようです。
そして、associations
は models
で取得した各モデルに対して reflect_on_all_associations
メソッドを実行した結果から、association として妥当なものを抽出しています。reflect_on_all_associations
という聴き慣れないメソッドが出て来ましたので、ドキュメント を読んでみます。
Returns an array of AssociationReflection objects for all the associations in the class.
訳)クラス内のすべてのアソシエーションについて、AssociationReflectionオブジェクトの配列を返します。
出て来ました、AssociationReflection
というワード。rails-erd は静的解析せずにこのリフレクションという機能を使っているので、動的に生成されたモデルでも検知できるとのことだったのですが、AssociationReflection とは一体何者なのでしょうか。ドキュメント の概要には以下のように記載されていました。
Holds all the metadata about an association as it was specified in the Active Record class.
訳)Active Record クラスで指定されたアソシエーションに関するすべてのメタデータを保持します。
関連付けに関する情報全てを管理するクラスのようですね。どうやらこのリフレクションが関連付けの親玉のようです。
イメージがつきやすいように、実際に reflect_on_all_associations メソッドを実行してみました。User → Profile に has_one、User → Cat に has_many の関連づけを持っている時は以下のようなデータが返って来ます。
> User.reflect_on_all_associations
=> [
#<ActiveRecord::Reflection::HasManyReflection:0x00007f8147a6ba60 @name=:cats, @scope=nil, @options={}, @active_record=User(id: integer, name: string, email: string, created_at: datetime, updated_at: datetime), @klass=nil, @plural_name="cats", @type=nil, @foreign_type=nil, @constructable=true, @association_scope_cache=#<Concurrent::Map:0x00007f8147a6b7b8 entries=0 default_proc=nil>>,
#<ActiveRecord::Reflection::HasOneReflection:0x00007f8142b01470 @name=:profile, @scope=nil, @options={}, @active_record=User(id: integer, name: string, email: string, created_at: datetime, updated_at: datetime), @klass=nil, @plural_name="profiles", @type=nil, @foreign_type=nil, @constructable=true, @association_scope_cache=#<Concurrent::Map:0x00007f8142b00f98 entries=0 default_proc=nil>>
]
User クラスがどのような関連づけを持っているのか返って来ました。なるほど🤓
ここから ERD で表現する関連付けやカーディナリティ情報を取得して来ていたんですね。
ここからは、RailsERD::Domain のサブクラスを見ていきます。
RailsERD::Domain::Entity クラス
Entity.from_models(self, models)
が何をやっているのかを見ていきます。
class Entity
class << self
def from_models(domain, models)
(concrete_from_models(domain, models) + abstract_from_models(domain, models)).sort
end
private
def concrete_from_models(domain, models)
models.collect { |model| new(domain, model.name, model) }
end
def abstract_from_models(domain, models)
models.collect(&:reflect_on_all_associations).flatten.collect { |association|
association.options[:as].to_s.classify if association.options[:as]
}.flatten.compact.uniq.collect { |name| new(domain, name) }
end
end
end
from_models
メソッドでは、concrete_from_models
と abstract_from_models
から取得して来たエンティティをソートしているだけですね。そして、concrete_from_models
では models から Entity クラスのインスタンスを作成しており、abstract_from_models
では models から先ほど出て来た AssociationReflection オブジェクトを取得し、options[:as]
から Entity クラスのインスタンスを作成しています。options[:as]
はポリモーフィック関連を定義するときなどに出てくる、あの as
です。
class User < ApplicationRecord
has_many :posts, as: :postable # <- これ
end
RailsERD::Domain::Attribute クラス
次に domain.entities.first.attributes
が何をやっているかを見ていきます。
Entity クラスでは以下のように attributes
メソッドを定義しています。
class Entity
def attributes
@attributes ||= generalized? ? [] : Attribute.from_model(domain, model)
end
end
generalized?
は抽象クラス、もしくはポリモーフィックの場合に true になるようです。つまり、抽象クラス、もしくはポリモーフィックの場合は属性はなく、それ以外の時は Attribute クラスから属性を取得してきています。
Attribute クラスを見てみます。
class Attribute
class << self
def from_model(domain, model)
attributes = model.columns.collect { |column| new(domain, model, column) }
# 一部省略
attributes
end
end
end
from_model
メソッドではカラムを取り出して、そのカラムごとに Attribute クラスのインスタンスを返しています。
対象となるカラムがプライマリーキーなのか、ユニークなのか、外部キーなのかなどの情報も Attribute クラスのインスタンスメソッドとして定義されています。詳細は割愛しますが、モデルが持つプライマリーキー情報や、バリデーション情報、後ほど出てくる関連付け情報などから判定していました。
RailsERD::Domain::Relation クラス
Relationship.from_associations(self, associations)
が何をやっているかを見ていきます。
class Relationship
class << self
def from_associations(domain, associations)
assoc_groups = associations.group_by { |assoc| association_identity(assoc) }
assoc_groups.collect { |_, assoc_group| new(domain, assoc_group.to_a) }
end
private
def association_identity(association)
Set[association_owner(association), association_target(association)]
end
# association_owner, association_target は省略
end
end
from_associatioonos
では、まず引数で受け取った associations をどのクラス間の関連付けなのかでグループ分けしています。例えば、User → Cat に has_many、Cat → User に belongs_to の関連付けがある場合、それらは一つの assoc_group
と見なされます。そして、そのように分けられたグループごとに、Relationship クラスのインスタンスにして返却しています。
カーディナリティの判定
では、具体的にカーディナリティをどのように判定しているかを見ていきます。細かな条件等はありますが、筆者がカーディナリティの判定において重要だと思うソースコードを抜粋しました。
def association_minimum(association)
minimum = association_validators(:presence, association).any? ||
foreign_key_required?(association) ? 1 : 0
# 一部省略
end
def association_maximum(association)
maximum = association.collection? ? N : 1
# 一部省略
end
例えば、association_minimum
の値が 1 かつ、association_maximum
の値が 1 の場合は、「必ず1つ持つ」という関係性になり、association_minimum
の値が 0 かつ、association_maximum
の値が N の場合は、「0以上の複数持つ」という関係性になります。
association_minimum
メソッドは、presence バリデーションがある、もしくは外部キーが必須の場合 1 となり、それ以外では 0 です。一方、association_maximum
メソッドは association.collection?
の場合 N となり、それ以外は 1 となります。collection?
は AssociationReflection クラスのインスタンスメソッドで、has_many もしくは has_and_belongs_to_many の場合に true を返します。こうやって判定していたんですね😎
RailsERD::Domain::Specialization クラス
最後に Specialization.from_models(self, models)
が何を行っているのか見ていきます。
class Specialization
class << self
def from_models(domain, models)
models = polymorphic_from_models(domain, models) +
inheritance_from_models(domain, models) +
abstract_from_models(domain, models)
models.sort
end
end
end
from_models
メソッドでは単に、polymorphic_from_models
, inheritance_from_models
, abstract_from_models
からモデルを取り出しているのみです。それぞれのメソッドも見ていきます。
polymorphic_from_models
def polymorphic_from_models(domain, models)
models.collect(&:reflect_on_all_associations).flatten.collect { |association|
[association.options[:as].to_s.classify, association.active_record.name] if association.options[:as]
}.compact.uniq.collect { |names| new(...省略) }
end
複雑なことをしているように見えますが、AssociationReflection オブジェクトから、options[:as] がある時は、そのモデルと as
で指定したモデルを抽出しているといったところでしょうか。判定材料は as
ですね。
inheritance_from_models
def inheritance_from_models(domain, models)
models.reject(&:descends_from_active_record?).collect { |model| new(...省略) }
end
descends_from_active_record?
は STI タイプの条件が必要でない時 true、つまり STI タイプの条件が必要な時、false となるメソッドらしいです。ですので、それを reject で集めているので、STI タイプが必要なモデルのみを抽出しているということですね。
abstract_from_models
def abstract_from_models(domain, models)
models.select(&:abstract_class?).collect(&:direct_descendants).flatten.collect { |model| new(...省略)}
end
抽象クラスを抽出し、その抽象クラス直下のクラスを取り出しています。
このようにして特殊なモデルも取り出していたんですね。
まとめ
延々とソースコードを追っていきましたが、簡単にまとめます。
- 大元は ActiveRecord::Base.descendants から得られる全モデル
- 特に、関連付けに関する情報は reflect_on_all_associations メソッドを実行して得られる AssociationReflection オブジェクト から得ている
- エンティティ、カラムの情報(カラム名、PK, UK, FKなど)、関連付け、カーディナリティは、結局のところモデルと AssociationReflection オブジェクトからうまく情報を取り出して得ている
最後に
rails-erd のソースコードを読みながらどうやってエンティティや関連付け情報を取得しているかを見ていきました。調べてみて思ったことは、Rails がすでにモデルの情報や関連付けの情報などは用意してくれていて、rails-erd ではその情報を ERD を生成しやすいように加工しているだけなんだなということです。リフレクション機能に関してはあまり詳しくありませんが、Rails はモデルを操作する仕組みがよくできているなと思いました。
各クラスの細かいインスタンスメソッドについては説明を飛ばしたため、説明不足な部分も多いかと思いますが、ActiveRecord::Base.descendants
が大元なんだなということだけでも伝われば幸いです。ソースコードもコメント多めでわかりやすく書かれているので、興味がある方は覗いてみてください。