3
0

More than 1 year has passed since last update.

Rails のポリモーフィック関連でカラムの値とは異なるモデルを使用する

Last updated at Posted at 2022-01-31

背景

あまりないかもしれませんが、例えばメインの Rails アプリで運用されてる DB にサブの Rails アプリからもアクセスしたいような場合に同じテーブルを違うネームスペースのクラスから扱いたい場合があります。

Rails のポリモーフィック関連はレコードの文字列をそのままクラス名として使うため、全く同じクラス名であれば問題ないですが別にネームスペースが切られていると面倒なことになります。

構成

具体的には以下のような場合です(最小限の部分のみ記載しています)。
あるメニューのリストのうちメニューそのもの、見出し部分、内容部分をそれぞれモデルにしています。

イメージ的には↓のような感じです。
Screenshot_20220131-210817769.jpg

またバージョンは ruby 3.0.1、rails 6.1.4.1 です。

schema.rb
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
db
# 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
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 という型を新しく定義して追加します。

config/initializers/sub_menu_type.rb
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

また SectionItem モデルのほうは has_one アソシエーションにおいて unscopewhereMenu モデルのレコードの 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::SectionSub::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 で起きています。

lib/active_support/inflector/methods.rb
    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 の呼び出し元を見てみます。

lib/active_record/inheritance.rb
      def polymorphic_class_for(name)
        if store_full_class_name
          ActiveSupport::Dependencies.constantize(name) # <= ココ
        else
          compute_type(name)
        end
      end

polymorphic_class_for の呼び出しは以下です。

lib/active_record/associations/belongs_to_polymorphic_association.rb
      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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0