【rails】ActionTextとポリモーフィック関連付け

前回書いてから1ヶ月経ってしまった。もちろんtechcampは終了(修了?)している。
以前記事を書いたときからそこまで状況は変わっていない。せっせとアプリ実装を進めている。

年末年始、今年は実家に帰ることもできなかったのでひたすらテストコードを書いていた。誇張ではなく本当に12/31も1/1も朝から晩までコード書いたりコード書いたりコード書いたりしていた。悪くない年越しだったと思う。

そこまで状況は変わっていないといいつつ、できることや知識は増えたと思う。前に行っていた「HerokuにあげたアプリのDBがいじれない問題」も解決したし、とりあえず開発環境のみだけどdockerに乗せた。

それをtravisCIと連携して自動でテストを流すところまで来たのはいいのだが、ローカルだとうまくいく結合テストがうまく行かない。selenium chromeのコンテナは立てているのだけれど、多分うまく連携できていないのか、結合テストで画面操作が必要なところだけことごとく失敗する。
ここ数日何時間も調べていろいろ試しているのだけどどうにもならず、とりあえず今日は距離を置くことにした。
シンプルに、dockerやdocker composeやCIツールの知識がまだまだ足りていない。

自分のアプリにActionTextを導入してみる

Udemyの講座でその存在を知ったrails6の新機能、ActionText
railsguides.jp

レシピやブログが保存できるアプリ実装をしているので、この機能は親和性があるなと思って導入してみた。導入自体は簡単。ただ、ガイドにもある通り事前にActive Strageをセットアップしておく必要はある。
Active Storage の概要 - Railsガイド

ActionTextをインストールしたあと、リッチテキストのフィールドを追加したいモデルで以下記述をする。

class Blog < ApplicationRecord
 has_rich_text :content
end

あとはview画面のフォーム内で参照すればOK。

# 中略
<div class="new-blog-form">
 <%= form_with model: [@recipe, @blog], local: true do |f| %>
  <div class="label">title</div>
  <%= f.text_field :title %>
  <div class="label">本文</div>
  <%= f.rich_text_area :content %>
  <%= f.submit '追加' %>
 <% end %>
</div>

こんな感じで簡単にリッチテキスト形式のフォームが作れる。すごい。
f:id:yuki_526:20210102155718p:plain

一番いいなと思ったのは、やっぱり画像をそのままドラッグ&ドロップで文中に埋め込むことができる点。例えば料理を作った記録を残したいときとか、手順と一緒に調理過程の画像を載せる、ということがリッチテキストなら簡単にできる(画像のサイズをどうするのか?という問題はあると思うので、本格的なサービスとかで使うにはもっとしっかり揉んでからという気はするけど、個人で記録を残す分には便利)。

加えて、今自分が作成しているアプリではレシピを保存するrecipe modelとブログ記事を保存するblog modelの両方でActionTextを使っており、当然同じテーブルに2種類のcontentがごっちゃになって保存されている。
これって一体全体どんな仕組みなんだ?と気になったので、Udemyの講座をもう一度見てみたり、ガイドを読んだり、ER図を書きながら自分でもじっくりDBのテーブルやカラムたちを観察してみた。

Active Storageのモデルについて

事前準備としてセットアップしたActive Strageは、もともとは「簡単に画像をアップロードできる機能」くらいにしか思っていなかった。ガイドの言い方をすると、

ファイルをActive Recordオブジェクトにアタッチする機能を提供します。
Active Storage の概要 - Railsガイド

ということらしい。使い方としては、Active Storageをインストールしたあとにファイルをもたせたいモデルに対して

class User < ApplicationRecord
 has_one_attached :picture
 # has_many_attached もある
end

と記述すればいい。

このActive Storaggeは導入された段階でDBに以下2つのテーブルが作られる。

  • active_storage_blobs
  • active_storage_attachments

このうち、実際のファイルに対応しているのはactive_storage_blobs(blobはBinary Large Object、画像などのバイナリデータを格納するデータ型)であり、active_storage_attachmentsは紐付けられたモデルとactive_storage_blobsの中間テーブルのような役割を果たしている。

以下はschema.rb内のactive_storage_attachmentsに関する記述。

create_table "active_storage_attachments", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
    t.string "name", null: false
    t.string "record_type", null: false
    t.bigint "record_id", null: false
    t.bigint "blob_id", null: false
    t.datetime "created_at", null: false
    t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
    t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
  end

active_storage_blobsが「blob_id」という名前の外部キーとして保持されている。
一方、「record_id」というのがモデル側と紐づくための外部キーだが、同時に「record_type」には紐づくモデルのクラス名が入り、実際はこの2つのキーでレコードを一意のものにしている(と思う)。

こちらはマイグレーションファイルの記述。

create_table :active_storage_attachments do |t|
      t.string     :name,     null: false
      t.references :record,   null: false, polymorphic: true, index: false
      t.references :blob,     null: false

      t.datetime :created_at, null: false

      t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
end

「record_id」には「polymorphic: true」のオプションがついている。これが、ポリモーフィック関連付け。ガイドにも以下記載がある。

active_storage_attachmentsは、使うモデルのクラス名を保存するポリモーフィックjoinテーブルです。モデルのクラス名を変更した場合は、このテーブルに対してマイグレーションを実行して背後のrecord_typeをモデルの新しいクラス名に更新する必要があります。
Active Storage の概要 - Railsガイド

ポリモーフィック関連付けとは

railsガイドはものすごくとっつきにくい時もあるが、ポリモーフィック関連付けに関してはガイドを読むのが一番わかり易い。

ポリモーフィック関連付けを使うと、ある1つのモデルが他の複数のモデルに属していることを、1つの関連付けだけで表現できます。たとえば、写真(picture)モデルがあり、このモデルを従業員(employee)モデルと製品(product)モデルの両方に従属させたいとします。この場合は以下のように宣言します。

class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end

class Employee < ApplicationRecord
  has_many :pictures, as: :imageable
end

class Product < ApplicationRecord
  has_many :pictures, as: :imageable
end

ポリモーフィックなbelongs_toは、他のあらゆるモデルから利用できる、(デザインパターンで言うところの)インターフェイスを設定する宣言とみなすこともできます。@employee.picturesとすると、写真のコレクションをEmployeeモデルのインスタンスから取得できます。

同様に、@product.picturesとすれば写真のコレクションをProductモデルのインスタンスから取得できます。
Active Record の関連付け - Railsガイド

というわけなので、Active Storageを導入すれば関連付けられるモデルは一つとは限らない。先程の通り、active_storage_attachmentsが中間テーブル的な役割をし、モデル名とそのidを保持しつつ、一方でそれに結びつくファイルのid(=blob_id)も保持している。ファイル保存先のテーブルは一つだが、このような仕組みで各レコードが一意になっている。


ActionTextとActive Storageの関係性

なんでこんなにActive Storageを紐解いたかと言うと、コンソールでActionTextクラスを見てみると以下のような記述がある。

[1] pry(main)> show-source ActionText::RichText

From: /usr/local/bundle/gems/actiontext-6.0.3.4/app/models/action_text/rich_text.rb:4
Class name: ActionText::RichText
Number of monkeypatches: 5. Use the `-a` option to display all available monkeypatches
Number of lines: 19

class RichText < ActiveRecord::Base
 # 中略
  belongs_to :record, polymorphic: true, touch: true
  has_many_attached :embeds
  # 以下略

つまり、ActionTextもポリモーフィック関連付けがされており、またActive Storageも紐付いている。そしてテーブル間でそれぞれ保持している情報も微妙に変わってくる。

ActionTextをインストールすると作られるテーブルは「action_text_rich_texts」だ。
マイグレーションファイルは以下の通り。

create_table :action_text_rich_texts do |t|
      t.string     :name, null: false
      t.text       :body, size: :long
      t.references :record, null: false, polymorphic: true, index: false

      t.timestamps

      t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
end

「body」カラムにはリッチテキストフィールドに入力された文字の内容(添付ファイル以外)が保存される。
そして、先程の「active_storage_attachments」テーブルと同じく「record_id」カラムと「record_type」カラムを持つので、外部キーとして紐付いたモデル名とそのidも保持することができる。

その一方で、今回ActionTextに従属的に紐付けられたactive_storage_attachmentsテーブルにおいて、「record_id」カラムと「record_type」カラムの役割は以下のようになる。

  • 「record_id」カラム:保存されるidは「action_text_rich_texts」のidになる。
  • 「record_type」カラム:保存されるモデル名は「ActionText::RichText」になる。

引き続きactive_storage_attachmentsテーブルは「blob_id」を外部キーとして持つので、リッチテキストフィールド内に添付されたファイルと結びつくことができる。


まとめ

個人の勝手な印象ではあるが、Active Storageだけで運用していたときは各モデルと直接結びついていたactive_storage_attachmentsテーブルが、Action Textに内包される(?)ことでaction_text_rich_textモデルとしか結びつくことができず、action_text_rich_textモデルとactive_storage_blobsモデルをつなげるだけの存在になってしまっているのが面白い。

また、ここらへんの詳しい解説を以下講座(参照欄)ではしてくださっているのだが、最初に見たときは全然意味がわからなかった。
自分でActionTextを導入し、テーブルなどいろいろ触れてみることでようやく各テーブルの関係性がわかってきた。

いろんな知識を上からじゃんじゃん入れていくのも大事だが、こうやって着実にわかることを増やしてくのも重要だし、「よくわからなかったものが理解できた」という状態って精神衛生上ものすごく良い体験だと思う。


(おまけ)ER図

空間把握能力なさすぎてER図書くのがしんどいししんどい思いして書いた割にはヘタクソ

f:id:yuki_526:20210102201843p:plain

作成中アプリの一部を切り出しました。