LoginSignup
0
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-10-20

はじめに

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

ここで、modelsassociations が新しく出てきたので、どのような内容が入っているのか確認します。

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 の関連性などで選別しているようです。

そして、associationsmodels で取得した各モデルに対して 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_modelsabstract_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 が大元なんだなということだけでも伝われば幸いです。ソースコードもコメント多めでわかりやすく書かれているので、興味がある方は覗いてみてください。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1