💎

STI で type 列に名前空間を除いたクラス名のみを格納する [Ruby on Rails]

2022/03/13に公開約4,200字

結論

store_full_sti_class というクラス変数があるので、スーパークラスで false をセットする。

class Chat::Room < ApplicationRecord
  self.store_full_sti_class = false
end

解説

ユースケース

モノリシックな大規模アプリケーションになってくると、モデルクラスも名前空間で整理したくなることがあります。例えば、チャットのサブシステムを作るにあたってテーブル名は chat_rooms, chat_members, chat_messages といった形で用意したいのですが、デフォルトでは app/models/chat_room.rb, app/models/chat_members, app/models/chat_messages とトップレベルに chat_ というプレフィックスが付いたファイルが大量にあると管理が辛いので app/models/chat/room.rb, app/models/chat/member.rb, app/models/chat/message.rb のように chat ディレクトリにまとめてしまい、クラスも ChatRoom, ChatMember, ChatMessage とするのではなく Chat::Room, Chat::Member, Chat::Message としておけば、Chat という名前空間内では「ルームと言えば当然チャットルームだよね」という世界観でいられるのでプログラミングが楽になります。

例えば、ここでチャットルームにはプライベートルームとパブリックルームとお知らせ専用の一方通行ルームの3つの種別があって、テーブルやデータ構造は同じだけど少しだけ種別毎にインスタンスメソッド等を用意したいため STI を使って chat_rooms テーブルの type 列にプライベートルームかパブリックルームかお知らせルームかをクラス名で格納することにしたとします。モデルクラスとしては Chat::PrivateRoomChat::PublicRoomChat::NoticeRoom のように作ってそれぞれ Chat::Room を継承することになるのですが、このまま Ruby on Rails のデフォルト状態で STI しようとすると type 列には Chat::PrivateRoom のように名前空間まで文字列で保存されてしまい、chat_rooms という名前のテーブルの属性に Chat:: という名前空間まで格納するのは少し気持ち悪いので取り除けないか、というのが本題です。

本体の実装を読み解く

「rails sti 名前空間」とか「rails sti without namespace」とか調べてみても誰も同じような悩みを抱えていないようなので、本体の実装を見に行くことにしました。STI 関連の実装は activerecord/lib/active_record/inheritance.rb にあります。

sti_name

さらっと読んでみて目を引くのは sti_name というクラスメソッドです。

# Returns the value to be stored in the inheritance column for STI.
def sti_name
  store_full_sti_class && store_full_class_name ? name : name.demodulize
end

このクラスメソッドが返す値が type 列に保存される文字列らしいので、これをオーバーライドしてやれば任意の文字列で type 列を管理することができます。

class Chat::PrivateRoom < Chat::Room
  def self.sti_name
    'PrivateRoom'
  end
end

また、名前空間付きの文字列("Chat::PrivateRoom")は name で取得でき、これを String#demodulize で名前空間を除いた文字列を取得することができます。あるいは ActiveRecord::Base.class_name で取得することもできます。name.demodulizeActiveRecord::Base.name はただのクラス変数であるのに対して、class_name はメソッドで table_name を元にクラス名を作り出す処理なので、いろんな意味で前者の方が良いかもしれません。これでスーパークラスに1つ書けば良いことになりました。

class Chat::Room < ApplicationRecord
  def self.sti_name
    name.demodulize
  end
end

store_full_class_name

任意の文字列を格納したい場合は sti_name をオーバーライドすれば良いわけですが、今回の目的はあくまで名前空間を取り除くことだけです。冒頭に結論を書いているのに、わざわざここまで読み進めている暇なお前らには既にピンと来ていたかもしれませんが、先ほどの sti_name の実装にそのまま答えが書いてあります。もう一度見てみましょう。

# Returns the value to be stored in the inheritance column for STI.
def sti_name
  store_full_sti_class && store_full_class_name ? name : name.demodulize
end

このメソッドは store_full_sti_classstore_full_class_name が共に真の場合には name(名前空間付き文字列)を、そうでない場合は name.demodulize(名前空間を除いた文字列)を返しています。つまり、store_full_sti_classstore_full_class_name のどちらかが偽であれば name.demodulize を返してくれるわけで、名前からすると store_full_sti_class を偽にするのが良さそうです。store_full_sti_class も同じく activerecord/lib/active_record/inheritance.rb に実装されています。これはただの class_attribute で作られたクラス変数で、デフォルトは true がセットされています。

# Determines whether to store the full constant name including namespace when using STI.
# This is true, by default.
class_attribute :store_full_sti_class, instance_writer: false, default: true

つまり、このクラス変数を false にしてやるだけで今回の課題は解決できるので sti_name をオーバーライドする必要はありません。

class Chat::Room < ApplicationRecord
  self.store_full_sti_class = false
end
> Chat::NoticeRoom.new.type
=> "NoticeRoom"

以上です。

Discussion

ログインするとコメントできます