rails-erd はどうやってエンティティや関連づけ情報を取得しているのか

## はじめに
ERD を自動生成しようと思い [rails-erd](https://github.com/voormedia/rails-erd) を触り始めたのですが、いい感じに関連付けなどを表現してくれるので、どのようにして関連付けを取得しているのかなー🧐と思い、調べてみた内容を記載しています。

と、本題に入る前に自分がすごいと思う rails-erd の特徴を述べさせてください🙇‍♂️
本題のスタートは [こちら](#ソースコードを読む) です。

## 私の考える rails-erd のすごい特徴３つ

#### 1. バリデーションから必須の判定を行ってくれる

ERD から素早くドメインロジックを理解するには、エンティティ間の関係性が正確に記述されている必要があります。１対１なのか、１対多なのか、多対多なのかは必須の情報ですよね。そして、そこにプラスで「必須かどうか」の情報があると私としてはすごく理解が早まります。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 ページ](https://voormedia.github.io/rails-erd/customise.html) には、[yuml](https://yuml.me/) というダイアグラムツールで出力する例が記載されています。もっと見やすくビジュアライズしてくれるツールがあれば、そちらに乗り換えるという選択ができるわけです。

## ソースコードを読む

ここからが本題です。ソースコードを追っていきながら読み解いていきます。

### 代表的なクラスの紹介

最初に、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](https://graphviz.org/) ベースの図を作成するためのクラス|

※ RailsERD::Diagram, RailsERD::Diagram::Graphviz クラスは、 RailsERD::Domain から取得される情報を元にダイアグラムを作成するクラスになるので、この記事ではこれ以上は触れません。

### RailsERD::Domain クラス

RailsERD::Domain がドメインモデルのスタートです。以下のように扱うことができます。

```ruby
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 は何を行っているのか

```ruby
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 メソッドでは何をやっているのか

```ruby
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` が新しく出てきたので、どのような内容が入っているのか確認します。

```ruby
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` という聴き慣れないメソッドが出て来ましたので、[ドキュメント](https://api.rubyonrails.org/classes/ActiveRecord/Reflection/ClassMethods.html) を読んでみます。

> Returns an array of AssociationReflection objects for all the associations in the class.


訳）クラス内のすべてのアソシエーションについて、AssociationReflectionオブジェクトの配列を返します。

出て来ました、`AssociationReflection` というワード。rails-erd は静的解析せずにこのリフレクションという機能を使っているので、動的に生成されたモデルでも検知できるとのことだったのですが、AssociationReflection とは一体何者なのでしょうか。[ドキュメント](https://www.rubydoc.info/docs/rails/ActiveRecord/Reflection/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)` が何をやっているのかを見ていきます。

```ruby
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` です。

```ruby
class User < ApplicationRecord
  has_many :posts, as: :postable # <- これ
end
```

### RailsERD::Domain::Attribute クラス

次に `domain.entities.first.attributes` が何をやっているかを見ていきます。

Entity クラスでは以下のように `attributes` メソッドを定義しています。 

```ruby
class Entity
  def attributes
    @attributes ||= generalized? ? [] : Attribute.from_model(domain, model)
  end
end
```

`generalized?` は抽象クラス、もしくはポリモーフィックの場合に true になるようです。つまり、抽象クラス、もしくはポリモーフィックの場合は属性はなく、それ以外の時は Attribute クラスから属性を取得してきています。

Attribute クラスを見てみます。

```ruby
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)` が何をやっているかを見ていきます。

```ruby
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 クラスのインスタンスにして返却しています。

#### カーディナリティの判定

では、具体的にカーディナリティをどのように判定しているかを見ていきます。細かな条件等はありますが、筆者がカーディナリティの判定において重要だと思うソースコードを抜粋しました。

```ruby
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 の場合は、「必ず１つ持つ」という関係性になり、`association_minimum` の値が 0 かつ、`association_maximum` の値が N の場合は、「０以上の複数持つ」という関係性になります。

`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)` が何を行っているのか見ていきます。

```ruby
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

```ruby
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

```ruby
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

```ruby
def abstract_from_models(domain, models)
  models.select(&:abstract_class?).collect(&:direct_descendants).flatten.collect { |model| new(...省略)}
end
```

抽象クラスを抽出し、その抽象クラス直下のクラスを取り出しています。

このようにして特殊なモデルも取り出していたんですね。


## まとめ

延々とソースコードを追っていきましたが、簡単にまとめます。

- 大元は <b>ActiveRecord::Base.descendants</b> から得られる<b>全モデル</b>
- 特に、関連付けに関する情報は <b>reflect_on_all_associations</b> メソッドを実行して得られる <b>AssociationReflection オブジェクト</b> から得ている
- エンティティ、カラムの情報（カラム名、PK, UK, FKなど）、関連付け、カーディナリティは、結局のところモデルと AssociationReflection オブジェクトからうまく情報を取り出して得ている

## 最後に

rails-erd のソースコードを読みながらどうやってエンティティや関連付け情報を取得しているかを見ていきました。調べてみて思ったことは、Rails がすでにモデルの情報や関連付けの情報などは用意してくれていて、rails-erd ではその情報を ERD を生成しやすいように加工しているだけなんだなということです。リフレクション機能に関してはあまり詳しくありませんが、Rails はモデルを操作する仕組みがよくできているなと思いました。

各クラスの細かいインスタンスメソッドについては説明を飛ばしたため、説明不足な部分も多いかと思いますが、`ActiveRecord::Base.descendants` が大元なんだなということだけでも伝われば幸いです。ソースコードもコメント多めでわかりやすく書かれているので、興味がある方は覗いてみてください。

## 参考
- [Rails ERD 公式ドキュメント](https://voormedia.github.io/rails-erd/)
- [rails-erd - github](https://github.com/voormedia/rails-erd)
- [ActiveRecord::Reflection::ClassMethods](https://api.rubyonrails.org/classes/ActiveRecord/Reflection/ClassMethods.html)
- [ActiveRecord::Reflection::AssociationReflection](https://www.rubydoc.info/docs/rails/ActiveRecord/Reflection/AssociationReflection)