Railsプロジェクトのデータベースの構造のER図をPlantUMLで自動的に吐き出せるようにしてみた

# モチベーション

QiitaでもPlantUMLを表示できるようになってきたのでいよいよPlantUMLが民主化されてきました。
そこで「データベースの情報を基に自動的にER図作ってくれたらいいのになぁ...」と思ったので勢いで作ってみました。

# 対象となる技術の選定と制限

ER図の特徴の都合上、各種リレーションの定義を取得するためにはORMが使用されているものを対象に作成する必要があります。
今回は以下が使用されている環境を想定してスクリプトを作成・実行しました

* MySQL
* Rails

これは実行テストを行った対象である 「ハッカソンの開催情報を自動でお知らせするBot [HackathonPortal](https://twitter.com/HackathonPortal)  」 の実行環境に合わせたためです。
同じ `Rails` でも `Postgresql` や `SQLite` においては実行確認していないのでうまくいくかわかりません。(試してみてうまくいったご報告をいただけましたら幸いです。その時は更新いたします)

# 実際に作成したソースコード

以下が実際に作成したコードになります

```ruby
  task export_entity_relationship_diagram_plantuml: :environment do
    # 使用されている全てのテーブルのModelの情報を取得するために全て読み込む
    Rails.application.eager_load!

    # 使用されている全てのテーブルを持っていて関係性がわかるModelの情報を取得する
    model_classes = ActiveRecord::Base.descendants.select { |m| m.table_name.present? }
    class_name_model_class_pair = model_classes.index_by(&:to_s)
    relation_entity_components = Set.new
    entity_component_fields = Set.new
    unique_index_table_columns = Set.new
    foreign_key_pairs = {}

    # ER図においてそれぞれのエンティティとの関連性を記述していく
    class_name_model_class_pair.values.each do |model_class|
      model_class
        .reflections
        .values
        .each do |relation_info|
          # polymorphic の belongs_to の構造はリレーション関係がわからないのでスルー
          next if relation_info.polymorphic?

          # belongs_to 参照元を取得する場合はfrom と toの対象を交換する
          if relation_info.instance_of?(ActiveRecord::Reflection::BelongsToReflection)
            to_model_class = model_class
            from_model_class = class_name_model_class_pair[relation_info.class_name]
          else
            from_model_class = model_class
            to_model_class = class_name_model_class_pair[relation_info.class_name]
          end
          primary_keys = [from_model_class.primary_key].flatten
          to_foreign_key_string = [to_model_class.table_name, relation_info.foreign_key].join('.')
          if relation_info.options[:primary_key].present?
            from_foreign_key_string = [from_model_class.table_name, relation_info.options[:primary_key]].join('.')
          else
            from_foreign_key_string =
              primary_keys.map { |primary_key| [from_model_class.table_name, primary_key].join('.') }.join(',')
          end

          # 外部キーのカラムとの関係性を記録する
          foreign_key_pairs[to_foreign_key_string] = from_foreign_key_string

          # has_many 関係性を表現 1対多の場合
          if relation_info.instance_of?(ActiveRecord::Reflection::HasManyReflection)
            # 0 ~ 複数
            relation_entity_components << [from_model_class.table_name, '--o{', to_model_class.table_name].join(' ')
            # has_one 関係性を表現 1対1の場合
          elsif relation_info.instance_of?(ActiveRecord::Reflection::HasOneReflection)
            # belongs_toの要素が先に登録されていたら消す
            relation_entity_components.delete(
              [from_model_class.table_name, '--o{', to_model_class.table_name].join(' '),
            )

            # 0 ~ 1
            relation_entity_components << [from_model_class.table_name, '|o--o|', to_model_class.table_name].join(' ')
            # has_many :through 関係性を表現 多対多の場合
          elsif relation_info.instance_of?(ActiveRecord::Reflection::ThroughReflection)
            relation_entity_components << [from_model_class.table_name, '}o--o{', to_model_class.table_name].join(' ')
            # belongs_to 参照元を取得 とりあえず 1対多として記録
          elsif relation_info.instance_of?(ActiveRecord::Reflection::BelongsToReflection)
            # has_one の要素が記録されていたらスキップ
            unless relation_entity_components.include?(
                     [from_model_class.table_name, '|o--o|', to_model_class.table_name].join(' '),
                   )
              relation_entity_components << [from_model_class.table_name, '--o{', to_model_class.table_name].join(' ')
            end
          end
        end

      model_class
        .connection
        .indexes(model_class.table_name)
        .each do |index_definition|
          # unique index(単体)カラムには印をつけるため該当するものを集める
          if index_definition.unique && index_definition.columns.size == 1
            unique_index_table_column = [model_class.table_name, index_definition.columns.first].join('.')
            unique_index_table_columns << unique_index_table_column
          end
        end
    end

    # ER図においてそれぞれのエンティティのカラムの特徴を記述していく
    class_name_model_class_pair.values.each do |model_class|
      primary_keys = [model_class.primary_key].flatten
      entity_components = []
      entity_components << ['entity', '"' + model_class.table_name + '"', '{'].join(' ')
      model_class.columns.each do |model_column|
        table_column_string = [model_class.table_name, model_column.name].join('.')
        if primary_keys.include?(model_column.name)
          entity_components << ['+', model_column.name, '[PK]', model_column.sql_type].join(' ')
          entity_components << '=='
          # 外部キーには目印
        elsif foreign_key_pairs[table_column_string].present?
          entity_components <<
            [
              '#',
              model_column.name,
              '[FK(' + foreign_key_pairs[table_column_string] + ')]',
              model_column.sql_type,
            ].join(' ')
          # unique indexには目印
        elsif unique_index_table_columns.include?(table_column_string)
          entity_components << ['*', model_column.name, model_column.sql_type].join(' ')
        else
          entity_components << [model_column.name, model_column.sql_type].join(' ')
        end
      end
      entity_components << '}'
      entity_components << "\n"
      entity_component_fields << entity_components.join("\n")
    end

    # PlantUMLを記述
    plntuml_components = Set.new
    plntuml_components << '```plantuml'
    plntuml_components << '@startuml'
    plntuml_components += entity_component_fields
    plntuml_components += relation_entity_components
    plntuml_components << '@enduml'
    plntuml_components << '```'
    export_plantuml_path = Rails.root.join('er-diagram.plantuml')
    File.write(export_plantuml_path, plntuml_components.to_a.join("\n"))
  end
```

上記のソースコードを `rails runner` または `rake task名`, `rails task名` にて実行することで、プロジェクト内のER図を表したPlantUMLファイルが出力されます

# 実行テスト

上記のスクリプトを実行してプロジェクトのER図を出力してみた内容が以下の通りになります

```plantuml
@startuml
entity "admin_users" {
+ id [PK] int(11)
==
* email varchar(255)
encrypted_password varchar(255)
* reset_password_token varchar(255)
reset_password_sent_at datetime
remember_created_at datetime
sign_in_count int(11)
current_sign_in_at datetime
last_sign_in_at datetime
current_sign_in_ip varchar(255)
last_sign_in_ip varchar(255)
created_at datetime
updated_at datetime
}


entity "events" {
+ id [PK] int(11)
==
event_id varchar(255)
type varchar(255)
title varchar(255)
url varchar(255)
shortener_url varchar(255)
description text
started_at datetime
ended_at datetime
limit_number int(11)
address varchar(255)
place varchar(255)
lat float
lon float
cost int(11)
max_prize int(11)
currency_unit varchar(255)
owner_id varchar(255)
owner_nickname varchar(255)
owner_name varchar(255)
attend_number int(11)
substitute_number int(11)
created_at datetime
updated_at datetime
informed_from int(11)
state int(11)
og_image_info text
}


entity "twitter_bots" {
+ id [PK] int(11)
==
tweet varchar(255)
tweet_id varchar(255)
from_type varchar(255)
# from_id [FK(events.id)] int(11)
tweet_time datetime
created_at datetime
updated_at datetime
}


entity "ai_hashtags" {
+ id [PK] bigint(20)
==
hashtag varchar(255)
}


entity "ai_resource_hashtags" {
+ id [PK] bigint(20)
==
resource_type varchar(255)
# resource_id [FK(events.id)] int(11)
# hashtag_id [FK(ai_hashtags.id)] int(11)
}


entity "blogger_bots" {
+ id [PK] bigint(20)
==
blogger_blog_id varchar(255)
blogger_post_id varchar(255)
title varchar(255)
url varchar(255)
date_number int(11)
tag_names varchar(255)
event_ids text
body mediumtext
created_at datetime
updated_at datetime
}


entity "event_calendar_bots" {
+ id [PK] bigint(20)
==
from_type varchar(255)
# from_id [FK(events.id)] int(11)
calender_event_id varchar(255)
created_at datetime
updated_at datetime
}


entity "promote_action_tweets" {
+ id [PK] bigint(20)
==
user_id varchar(255)
# status_user_id [FK(promote_users.user_id)] varchar(255)
status_user_screen_name varchar(255)
status_id varchar(255)
state int(11)
score float
created_at datetime
lang varchar(255)
}


entity "promote_friends" {
+ id [PK] bigint(20)
==
type varchar(255)
from_user_id varchar(255)
# to_user_id [FK(promote_users.user_id)] varchar(255)
state int(11)
score float
followed_at datetime
record_followers_follower_counter int(11)
}


entity "promote_users" {
+ id [PK] bigint(20)
==
user_id varchar(255)
type varchar(255)
screen_name varchar(255)
state int(11)
follower_count int(11)
follow_count int(11)
}


entity "qiita_bots" {
+ id [PK] int(11)
==
* qiita_id varchar(255)
title varchar(255)
url varchar(255)
season_number int(11)
tag_names varchar(255)
event_ids text
body mediumtext
rendered_body mediumtext
created_at datetime
updated_at datetime
}


entity "active_admin_comments" {
+ id [PK] bigint(20)
==
namespace varchar(255)
body text
resource_type varchar(255)
resource_id bigint(20)
author_type varchar(255)
author_id bigint(20)
created_at datetime(6)
updated_at datetime(6)
}


events |o--o| event_calendar_bots
events |o--o| twitter_bots
events --o{ ai_resource_hashtags
events }o--o{ ai_hashtags
ai_hashtags --o{ ai_resource_hashtags
promote_users --o{ promote_action_tweets
promote_users --o{ promote_friends
@enduml
```

ER図を輩出してみたことで、使用していないテーブルやポリモーフィックの多様などデータ構造におけるアンチパターンを多用していることが判明したので、本プロジェクトについては後日テーブル構造を見直そうと思います...

また [HackathonPortal](https://twitter.com/HackathonPortal) のプロジェクトやソースコードは[こちら](https://github.com/TakuKobayashi/hackathon_portal)にて公開していますので興味がある方はこちらも参照してください。
処理の中身の解説などはこちらを参照してください

* [最新のハッカソンの開催情報を自動で集めて、お知らせするBotを作ったので頭の中を紹介](https://qiita.com/taptappun/items/f913dc8daaec931508a1)

まだフォローしていない方はフォローのほどよろしくお願いします。
[@HackathonPortal](https://twitter.com/HackathonPortal)

# これから

希望が多くありましたら、汎用的に使用できるように `gem化` しようと思います。
また勢いで作ったので汎用化にあたり漏れているユースケースがあるかもしれません。
その場合は都度更新していきたいと思います。

# 参考

* [PlantUMLでER図を書いて、githubで管理してみた](https://qiita.com/2ndponpu/items/23210cd080d358780d79)
* [やさしい図解で学ぶ　ER図　表記法一覧](https://qiita.com/ramuneru/items/32fbf3032b625f71b69d)