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

## 背景

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

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


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


イメージ的には↓のような感じです。
![Screenshot_20220131-210817769.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189229/2c924db4-39bd-8d24-701c-ef30df519f2b.jpeg)

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

```ruby: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
```


```mysql: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    |                |
+-----------------+--------------+------+-----+---------+----------------+
```


上記のテーブルを扱うモデルがそれぞれ以下です。

```ruby:メインシステムのモデル
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
```

これを以下のようなサブシステムのモデルから扱いたいと思います。

```ruby:サブシステムのモデル
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
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` という型を新しく定義して追加します。

```ruby: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

また `Section` や `Item` モデルのほうは `has_one` アソシエーションにおいて `unscope` と `where` で `Menu` モデルのレコードの `WHERE` 句をそれぞれ指定します。


```ruby:サブシステムのモデル
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` の記述でそのまま実行してみます。

```ruby
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` で起きています。

```ruby: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` の呼び出し元を見てみます。

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

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

```ruby: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