📝

ActiveRecord から取得した DB 情報で ER 図を作(ってい)る(途中)

に公開

要望により、いわゆる DB 項目書のようなテーブルとテーブル間の関係、テーブルにどのような項目があるかがわかる資料が欲しいとなったため、これらを出力するものを作ったのでそのメモをまとめて置いておく。

要望(欲しいもの)

DB にもっているテーブルと、そのテーブル内のカラム、テーブル同士の簡単な関係を理解できるものが欲しい。
継続的な成長を伴うシステムだと作成しているそばからテーブルやカラムが追加されて陳腐化するので、自動で生成したい。

自作することになったきっかけ

既存のライブラリや仕組みを使って楽をしたかったが、以下の要望を満たそうと思ったら自作することになった。

  1. テーブル数があるので、すべてを表示すると小さくて何も見えない。グループ化して一部だけ表示したい。
  2. すべてのテーブルを表示する必要はない。出力対象外としたいテーブルがある。
  3. 関係がわかればよく、正確・厳密よりは、わかりやすさを重視したい。

rails-mermaid_erdrails-mermaid_erd_markdown を使えればよかったのだが、手を入れる量が多そうだったことと、そこまで手を入れるなら依存関係を増やさないほうがいいだろうと考えて、自作することにした。

実現方法

以下の 3 steps で実現することとした。

  1. ApplicationRecord を継承したクラス(テーブルに対応したモデル)を抽出し、このクラスの情報と関連情報を取得する
  2. 取得した情報から、Mermaid の ER 図を出力する
  3. 出力した ER 図の文字列を Notion に定期的に反映して閲覧できるようにする(途中)

これまでの経験から 1 によって、出力に必要な情報を取得できることがわかっていた。
2 は、Mermaid の ER 図ならば表示するカラムを増やすことができるので、型やカラム名と一緒にカラムの日本語名も併せて表記できると考えたため。
3 は、インフラを追加したくなかったため、普段から使っている Notion に反映して閲覧できるようにしたかった。

Notion への反映は、GitHub Actions を使うことを考えているが、現状、Notion への自動反映の実装にはまだ至っていない。(次回以降の宿題)

構成要素

前述の実現方法の 1, 2 を実現する大まかな構成としては、以下のような構成を考えた。

  1. テーブルや関連情報を収集するクラス(実現方法の 1 を担当する)
  2. モデルの情報を収集するクラス
  3. 関連情報を収集するクラス
  4. Mermaid 記法で ER 図を出力するクラス(実現方法の 2 を担当する)
  5. 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 による設定を見て出力対象かどうかを判断するようにしている。
設定ファイルでは、グループ化するためのグループ情報と、図上で無視するグループや無視するテーブルを管理している。

ここでは以下のようなポイントがある。

  1. 日本語を出力する際はクウォートで囲まないとシンタックスエラーになるため、日本語名はダブルクウォートで囲っている。
  2. 関係線を簡素にするため、左から右のモデルをみて、has_many/has_one の関連のみ扱う
  3. 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 行超くらいで自作できることがわかった。

参考

https://github.com/koedame/rails-mermaid_erd
https://github.com/humzahkiani/rails-mermaid_erd_markdown
https://api.rubyonrails.org/classes/ActiveRecord/Reflection/ClassMethods.html#method-i-reflections
https://mermaid.js.org/syntax/entityRelationshipDiagram.html

あしたのチーム Tech Blog

Discussion