ActiveRecord から取得した DB 情報で ER 図を作(ってい)る(途中)
要望により、いわゆる DB 項目書のようなテーブルとテーブル間の関係、テーブルにどのような項目があるかがわかる資料が欲しいとなったため、これらを出力するものを作ったのでそのメモをまとめて置いておく。
要望(欲しいもの)
DB にもっているテーブルと、そのテーブル内のカラム、テーブル同士の簡単な関係を理解できるものが欲しい。
継続的な成長を伴うシステムだと作成しているそばからテーブルやカラムが追加されて陳腐化するので、自動で生成したい。
自作することになったきっかけ
既存のライブラリや仕組みを使って楽をしたかったが、以下の要望を満たそうと思ったら自作することになった。
- テーブル数があるので、すべてを表示すると小さくて何も見えない。グループ化して一部だけ表示したい。
- すべてのテーブルを表示する必要はない。出力対象外としたいテーブルがある。
- 関係がわかればよく、正確・厳密よりは、わかりやすさを重視したい。
rails-mermaid_erd や rails-mermaid_erd_markdown を使えればよかったのだが、手を入れる量が多そうだったことと、そこまで手を入れるなら依存関係を増やさないほうがいいだろうと考えて、自作することにした。
実現方法
以下の 3 steps で実現することとした。
- ApplicationRecord を継承したクラス(テーブルに対応したモデル)を抽出し、このクラスの情報と関連情報を取得する
- 取得した情報から、Mermaid の ER 図を出力する
- 出力した ER 図の文字列を Notion に定期的に反映して閲覧できるようにする(途中)
これまでの経験から 1 によって、出力に必要な情報を取得できることがわかっていた。
2 は、Mermaid の ER 図ならば表示するカラムを増やすことができるので、型やカラム名と一緒にカラムの日本語名も併せて表記できると考えたため。
3 は、インフラを追加したくなかったため、普段から使っている Notion に反映して閲覧できるようにしたかった。
Notion への反映は、GitHub Actions を使うことを考えているが、現状、Notion への自動反映の実装にはまだ至っていない。(次回以降の宿題)
構成要素
前述の実現方法の 1, 2 を実現する大まかな構成としては、以下のような構成を考えた。
- テーブルや関連情報を収集するクラス(実現方法の 1 を担当する)
- モデルの情報を収集するクラス
- 関連情報を収集するクラス
- Mermaid 記法で ER 図を出力するクラス(実現方法の 2 を担当する)
- Mermaid 記法で ER 図を出力する処理を実行する rake タスク
1 から 4 のクラスを作り、設定ファイルを YAML で作成して、rake タスクで実行するかたちを採ることにした。
実装 1: テーブルや関連情報を収集する
ポイントは、テーブル情報を取得するために、あらかじめモデルクラスをロードしておくということ。
production モードや、config/environment.rb で config.eager_load = true
が設定されている場合は不要だが、大抵の development モードでは config.eager_load は false なので、あらかじめ ApplicationRecord を継承したモデルクラスのロードが必要となる。
モデルクラスをロードしない場合はテーブル情報が取得できず、期待する結果が得られない。
module ErdMermaid
class ModelInformationLoader # テーブル・関連情報を収集するクラス
class << self
def load
# モデルクラスをロードする
Rails.autoloaders.main.eager_load_dir(Rails.root.join("app/models"))
hash = { tables: [], relations: {} }
ApplicationRecord.descendants.sort_by(&:name).each_with_object(hash) do |klass, acc|
# テーブル情報を収集する
acc[:tables] << ModelInformationCollector.new(klass)
# モデル同士の関連情報を収集する
relation_collector = RelationCollector.new(klass)
relation_collector.collect_relations
acc[:relations][klass.table_name] = relation_collector
end
end
end
end
end
実装 2: モデルの情報を収集するクラス
ER図には、テーブル名とその日本語名、型、カラム名、カラムの日本語名を表示するため、これらの情報を収集している。
module ErdMermaid
I18N_ATTRIBUTE_SCOPE = "activerecord.attributes"
I18N_MODEL_SCOPE = "activerecord.models"
class Collector # 収集親クラス
attr_reader :klass
def initialize(klass)
@klass = klass
end
delegate :table_name, to: :@klass
private
def translate(key, scope:)
I18n.t(key, scope:, locale: :ja, default: "")
end
end
class ModelInformationCollector < Collector
delegate :name, to: :klass # モデルクラス名
# テーブル名の日本語名
def name_ja
translate(name.underscore, scope: I18N_MODEL_SCOPE)
end
# カラム情報
def attributes
@attributes ||= klass.columns.each_with_object({}) do |column, acc|
acc[column.name] = {
name: column.name,
name_ja: translate(column.name, scope: [ I18N_ATTRIBUTE_SCOPE, klass.name.underscore ]),
type: column.type
}
end
end
end
end
実装 3: 関連情報を収集するクラス
モデルクラスから .reflections
を使って関連情報を収集するようにした。
双方向の関係を厳密に示して関連を示す線が多くなって見辛くなることを避けるため、左のエンティティから右のエンティティに対する has_many/has_one の関係のみを扱っている。
また、汎用的にするには has_and_belongs_to_many
の関係も考慮する必要があるが、普段使っていないためそのケースは未実装になっている。
ただし、has_and_belongs_to_many
の関係が現れた場合は例外が発生するようになっているので、発生したらちゃんと考えることにしている。
Data クラスは使ってみたかっただけである。
module ErdMermaid
class RelationCollector < Collector
attr_reader :relations
# 関連の左側エンティティの名前
def left_name = table_name
# 関連の右側エンティティの名前
def right_name(reflection) = reflection.table_name
def collect_relations
@relations = @klass.reflections.values.filter_map do |reflection|
if reflection.is_a?(::ActiveRecord::Reflection::HasAndBelongsToManyReflection)
raise "has_and_belongs_to_many は使っていないので想定していない"
end
next if reflection.is_a?(::ActiveRecord::Reflection::ThroughReflection)
next if reflection.is_a?(::ActiveRecord::Reflection::BelongsToReflection)
Relation.new(left: left_name, right: right_name(reflection), klass:, reflection:)
end
end
end
Relation = Data.define(:left, :right, :klass, :reflection) do
def table_name = left
def relation
case reflection
when ::ActiveRecord::Reflection::HasManyReflection
:has_many
when ::ActiveRecord::Reflection::HasOneReflection
:has_one
else
raise "想定していないクラス: #{reflection.class.name}"
end
end
def has_many? = relation == :has_many
def has_one? = relation == :has_one
# TODO: モデル名と、関連で使うモデル名を別々につくっているのがイケてない。重複排除したい
def left_with_ja
klass_name_ja = translate(klass.name.underscore, scope: I18N_MODEL_SCOPE)
klass_name_ja.present? ? "#{left}: #{klass_name_ja}" : left
end
# TODO: モデル名と、関連で使うモデル名を別々につくっているのがイケてない。重複排除したい
def right_with_ja
right_klass_name_ja = translate(reflection.klass.name.underscore, scope: I18N_MODEL_SCOPE)
right_klass_name_ja.present? ? "#{right}: #{right_klass_name_ja}" : right
end
private
# TODO: これがいろんなクラスでそれぞれ定義されているのでイケてない。重複排除したい
def translate(key, scope:) = I18n.t(key, scope:, locale: :ja, default: "")
end
end
実装 4: Mermaid 記法で ER 図を出力するクラス
収集したモデルと関連の情報を使って Mermaid の ER 図を出力するためのクラスで、YAML による設定を見て出力対象かどうかを判断するようにしている。
設定ファイルでは、グループ化するためのグループ情報と、図上で無視するグループや無視するテーブルを管理している。
ここでは以下のようなポイントがある。
- 日本語を出力する際はクウォートで囲まないとシンタックスエラーになるため、日本語名はダブルクウォートで囲っている。
- 関係線を簡素にするため、左から右のモデルをみて、has_many/has_one の関連のみ扱う
- Mermaid の ER 図は関係線に対してラベルが必須だが、ラベルをつけたくなかったため、固定で空文字(
""
) を当てている
module ErdMermaid
# erd_mermaid.yml に以下のように書く
#
# groups:
# ユーザー: # グループ名
# - users # グループに属するテーブル名のリスト
# - profiles
# - ...
# ignored_groups:
# - 非同期処理 # 無視するグループ名
# - ...
# ignored_tables:
# - active_storage_attachments # 無視するテーブル名
# - active_storage_blobs
# - ...
#
class Config
def initialize = @config = YAML.load_file(Rails.root.join("erd_mermaid.yml"))
def groups = @config["groups"]
def ignored_groups = @config["ignored_groups"]
def ignored_tables = @config["ignored_tables"]
end
class GroupsErDiagramOutput
attr_reader config, :collected_hash
def initialize(config, collected_hash)
@config = config
@collected_hash = collected_hash
end
def execute
config.groups.each do |group_name, grouped_table_names|
next if config.ignored_groups.include?(group_name)
model_texts = ModelTextBuilder.build(collected_hash[:tables], grouped_table_names)
relation_texts = RelationTextBuilder.build(collected_hash[:relations], grouped_table_names)
mermaid_text = <<~TEXT
erDiagram
#{model_texts}
#{relation_texts}
TEXT
Rails.root.join("erd_#{group_name}.md").write(mermaid_text)
end
end
class TextBuilderBase
private
# NOTE: 日本語を出力するには "" で囲む必要がある
def double_quoted(str) = "\"#{str}\""
def translate(key, scope:) = I18n.t(key, scope:, locale: :ja, default: "")
end
class ModelTextBuilder < TextBuilderBase
class << self
def select(model_collectors, grouped_table_names)
model_collectors.select { |collector| grouped_table_names.include?(collector.table_name) }
end
def build(model_collectors, grouped_table_names)
select(model_collectors, grouped_table_names).map { |collector| new(collector).text }.join("\n")
end
end
attr_reader :collector
def initialize(collector)
super()
@collector = collector
end
def text
attribute_lines = collector.attributes.values.map { |attr| " #{attribute_line(attr)}" }.join("\n")
<<~TEXT
#{model_name} {
#{attribute_lines}
}
TEXT
end
private
def attribute_line(attr)
"#{attr[:type]} #{attr[:name]} #{double_quoted(attr[:name_ja])}".strip
end
def model_name
if collector.name_ja.present?
double_quoted("#{collector.table_name}: #{collector.name_ja}")
else
collector.table_name
end
end
end
class RelationTextBuilder < TextBuilderBase
class << self
def select(relations, table_names)
relations.select do |relation|
table_names.include?(relation.right)
end
end
def build(relation_collectors_hash, table_names)
table_names.filter_map do |table_name|
relation_collector = relation_collectors_hash[table_name]
next unless relation_collector
selected_relations = select(relation_collector.relations, table_names)
next if selected_relations.blank?
selected_relations.map { |relation| RelationTextBuilder.new(relation).text }
end.flatten.join("\n")
end
end
attr_reader :relation
def initialize(relation)
super()
@relation = relation
end
# |Value (left) | Value (right) | Meaning |
# | ----------- | ------------- | -------------------------------
# | |o | o| | Zero or one |
# | || | || | Exactly one |
# | }o | o{ | Zero or more (no upper limit) |
# | }| | |{ | One or more (no upper limit) |
# NOTE: through, belongs_to の関係を除外しているので、左は Exactly one 扱い
def left_mark = "||"
# NOTE: through, belongs_to の関係を除外しているので、右は has_many or has_one
def right_mark
if relation.has_many?
"o{"
elsif relation.has_one?
"o|"
end
end
# NOTE: Mermaid の ER図は関連の線にラベルが必須なので、空文字を設定してラベルなしとして扱う
def relationship_label = double_quoted("")
def text
[
double_quoted(relation.left_with_ja),
"#{left_mark}--#{right_mark}",
"#{double_quoted(relation.right_with_ja)} : #{relationship_label}"
].join(" ")
end
end
end
end
実装 5: Mermaid 記法で ER 図を出力する処理を実行する rake タスク
ここまでに実装したクラスを呼び出し、Mermaid の ER 図を markdown ファイルに出力する rake タスクである。
今回は実行時にオプションや引数を必要としなかったため、rake タスクを採用した。
オプション等で挙動を変えたいという要件があったならば Thor を使っていたが、ここではその要件はないので rake タスクを使った。
require "erd_mermaid"
namespace :db do
desc "export mermaid erd"
task export_relations_to_mermaid: :environment do
hash = ErdMermaid::ModelInformationLoader.load
config = ErdMermaid::Config.new
ErdMermaid::GroupsErDiagramOutput.new(config, hash).execute
end
end
出力例
```mermaid ``` で貼り付けてみたがエラーになったので、出力したものをテキストとして貼った。
Notion の ```mermaid ``` では動作することを確認済。
※ channels と workspaces には翻訳を当てていない。
erDiagram
channels {
integer id ""
string name "channel名"
string slack_channel_id "チャネルID"
integer workspace_id "ワークスペースID"
datetime created_at "作成日時"
datetime updated_at "更新日時"
}
"entries: エントリー" {
integer id ""
string title "発表タイトル"
integer status "ステータス"
integer channel_id "チャネルID"
integer user_id "発表者"
datetime created_at "作成日時"
datetime updated_at "更新日時"
}
"users: 発表者" {
integer id ""
string name "ユーザー名"
string slack_user_id "user_id"
datetime created_at "作成日時"
datetime updated_at "更新日時"
}
workspaces {
integer id ""
string name "ワークスペース名"
datetime created_at "作成日時"
datetime updated_at "更新日時"
string slack_team_id "team_id"
}
"workspaces" ||--o{ "channels" : ""
"channels" ||--o{ "entries: エントリー" : ""
"users: 発表者" ||--o{ "entries: エントリー" : ""```
Notion に貼り付けた際、表示される ER 図のスクリーンショット
まとめ
DB の情報をまとめた資料を人類が手で作るには負荷が大きすぎるため、自動で生成する仕組みを用意した。
既存のライブラリを使って楽をしたかったが、カスタマイズの必要性と依存関係の追加を避けたい点から自作することにした。
割り切った仕様と Ruby on Rails に乗っかるならばトータル 300 行超くらいで自作できることがわかった。
参考
Discussion