背景
あまりないかもしれませんが、例えばメインの Rails アプリで運用されてる DB にサブの Rails アプリからもアクセスしたいような場合に同じテーブルを違うネームスペースのクラスから扱いたい場合があります。
Rails のポリモーフィック関連はレコードの文字列をそのままクラス名として使うため、全く同じクラス名であれば問題ないですが別にネームスペースが切られていると面倒なことになります。
構成
具体的には以下のような場合です(最小限の部分のみ記載しています)。
あるメニューのリストのうちメニューそのもの、見出し部分、内容部分をそれぞれモデルにしています。
またバージョンは ruby 3.0.1、rails 6.1.4.1 です。
create_table "menus", charset: "utf8mb4", force: :cascade do |t|
t.string "menu_type", null: false
t.integer "menu_id", null: false
end
create_table "menu_items", charset: "utf8mb4", force: :cascade do |t|
t.string "label", null: false
t.string "image"
end
create_table "menu_sections", charset: "utf8mb4", force: :cascade do |t|
t.string "headline", null: false
end
# menus テーブル
+-----------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-----------------+--------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| menu_type | varchar(255) | NO | MUL | NULL | |
| menu_id | int(11) | NO | | NULL | |
+-----------------+--------------+------+-----+---------+----------------+
# menu_sections テーブル
+------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| headline | varchar(255) | NO | | NULL | |
+------------+--------------+------+-----+---------+----------------+
# menu_items テーブル
+-----------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-----------------+--------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| label | varchar(255) | NO | | NULL | |
| item_type | tinyint(4) | NO | | NULL | |
+-----------------+--------------+------+-----+---------+----------------+
上記のテーブルを扱うモデルがそれぞれ以下です。
module Main
class Menu < ApplicationRecord
belongs_to :content, polymorphic: true, foreign_type: :menu_type, foreign_key: :menu_id
end
end
module Main
class Menu
class Item < ApplicationRecord
has_one :menu, as: :content, class_name: 'Main::Menu', foreign_type: :menu_type, foreign_key: :menu_id
end
end
end
module Main
class Menu
class Section < ApplicationRecord
has_one :menu, as: :content, class_name: 'Main::Menu', foreign_type: :menu_type, foreign_key: :menu_id
end
end
end
これを以下のようなサブシステムのモデルから扱いたいと思います。
module Sub
class Menu < ApplicationRecord
end
end
module Sub
class Menu
class Section < ApplicationRecord
end
end
end
module Sub
class Menu
class Item < ApplicationRecord
end
end
end
そして以下のようなレコードが入っているとします。
mysql> select * from main_menus;
+-----+---------------------+---------+
| id | menu_type | menu_id |
+-----+---------------------+----------
| 1 | Main::Menu::Section | 1 |
| 2 | Main::Menu::Item | 1 |
+-----+---------------------+---------+
この menu_type
のレコードの値を読み替えるにはどうすれば良いでしょうか?
結論
結論から言うと rails 5 の attributes API を使って実現することが出来ます。
以下のように SubMenuType
という型を新しく定義して追加します。
module Sub
class SubMenuType < ActiveRecord::Type::String
def cast(value)
value&.sub(/Main::/, 'Sub::')
end
end
end
ActiveRecord::Type.register(:sub_menu_type, Sub::SubMenuType)
これを Sub::Menu
モデルで扱う menu_type
の型として attribute
メソッドを使って定義します。
attribute API に関してはすでに色々記事が出ていますのでそちらをご参照ください。
https://techracho.bpsinc.jp/hachi8833/2020_12_23/48422
https://qiita.com/hamajyotan/items/0c1281d0156f89dcbe98
また Section
や Item
モデルのほうは has_one
アソシエーションにおいて unscope
と where
で Menu
モデルのレコードの WHERE
句をそれぞれ指定します。
module Sub
class Menu < ApplicationRecord
attribute :menu_type, :sub_menu_type
belongs_to :content, polymorphic: true, foreign_type: :menu_type, foreign_key: :menu_id
end
end
module Sub
class Menu
class Section < ApplicationRecord
has_one :menu, lambda { |_obj|
unscope(where: :menu_type).where(menu_type: 'Main::Menu::Section')
}, class_name: 'Sub::Menu', as: :section, foreign_type: :menu_type,
foreign_key: :menu_id, inverse_of: :content, dependent: :destroy
end
end
end
module Sub
class Menu
class Item < ApplicationRecord
has_one :menu, lambda { |_obj|
unscope(where: :menu_type).where(menu_type: 'Main::Menu::Item')
}, class_name: 'Sub::Menu', as: :item, foreign_type: :menu_type,
foreign_key: :menu_id, inverse_of: :content, dependent: :destroy
end
end
end
これでレコードの値がそのままでも Sub::Menu
インスタンスから紐づく Sub::Menu::Section
や Sub::Menu::Item
インスタンスを呼べたり、Sub::Menu.includes(:content)
のように includes
(動作的には preload
)を使うことも出来ます。
おまけ
コードリーディング
以下のようにサブシステムの方でメインシステムと同じように belongs_to
の記述でそのまま実行してみます。
module Sub
class Menu < ApplicationRecord
belongs_to :content, polymorphic: true, foreign_type: :menu_type, foreign_key: :menu_id
end
end
すると以下のようなエラーログになりました。これを追っていってみます。
activesupport (6.1.4) lib/active_support/inflector/methods.rb:288:in `const_get'
activesupport (6.1.4) lib/active_support/inflector/methods.rb:288:in `block in constantize'
activesupport (6.1.4) lib/active_support/inflector/methods.rb:284:in `each'
activesupport (6.1.4) lib/active_support/inflector/methods.rb:284:in `inject'
activesupport (6.1.4) lib/active_support/inflector/methods.rb:284:in `constantize'
activesupport (6.1.4) lib/active_support/dependencies/zeitwerk_integration.rb:19:in `constantize'
activerecord (6.1.4) lib/active_record/inheritance.rb:199:in `polymorphic_class_for'
activerecord (6.1.4) lib/active_record/associations/belongs_to_polymorphic_association.rb:9:in `klass'
...
これを少しずつ追っていきます。
ActiveSupport::Inflector#constantize
エラーの直接の原因は ActiveSupport::Inflector#constantize
内の const_get
で起きています。
def constantize(camel_cased_word)
if camel_cased_word.blank? || !camel_cased_word.include?("::")
Object.const_get(camel_cased_word)
else
names = camel_cased_word.split("::")
# Trigger a built-in NameError exception including the ill-formed constant in the message.
Object.const_get(camel_cased_word) if names.empty?
# Remove the first blank element in case of '::ClassName' notation.
names.shift if names.size > 1 && names.first.empty?
names.inject(Object) do |constant, name|
if constant == Object
constant.const_get(name)
else
candidate = constant.const_get(name) # <= ココ
next candidate if constant.const_defined?(name, false)
next candidate unless Object.const_defined?(name)
...
end
end
end
end
288行目の constant.const_get(name)
でエラーになっているので、この name
の元となっている引数、つまり constantize
の呼び出し元を見てみます。
def polymorphic_class_for(name)
if store_full_class_name
ActiveSupport::Dependencies.constantize(name) # <= ココ
else
compute_type(name)
end
end
polymorphic_class_for
の呼び出しは以下です。
def klass
type = owner[reflection.foreign_type]
type.presence && owner.class.polymorphic_class_for(type) # <= ココ
end
これは名前からわかるようにポリモーフィック関連用の belongs_to
を扱うためのクラスです。この klass
メソッドでいい感じのクラス名を返せると目的が達成できそうです。そこでカラム情報が定義されている owner
オブジェクトをなんとかしようという発想にいたります。
そして、実はこれ以降を辿っていってもオプションや設定でポリモーフィック関連のクエリを変更出来るところはありません。もし gem のオーバーライドで対応するのであればこの辺を上書きするところでした。
終わりに
ランチェスターではサーバーサイドエンジニアを募集しています!まずはざっくばらんにお話ししてみませんか?
下記からご応募お待ちしております。
▼ https://herp.careers/v1/lanchester/Bucw0mXRKogc
▼ https://www.wantedly.com/companies/lanchester
▼採用動画について:https://moovy.jp/job/651