Anthony Chao

Dealing ActiveRecord Polymorphic Relation With Namespace

#Jenkins

Intro

工作上碰到了一個要處理 ActiveRecord 多型關聯的相關工作,覺得有點難記得,所以就來記錄一下

Outline

Customize Polymorphic Relation

在 ActiveRecord 裡面使用 active record 的多型會把 model name 存在資料庫裡面,因此如果加上 namespace 就會有問題

接下來簡單描述一下什麼情況會遇到

多型的定義通常是這樣,在 has_one 或者 has_many 那邊是沒有標註多型的,但在資料庫搜尋的時候就會自動轉換

class A
  has_one :key, as: :target
end

class B
  has_one :key, as: :target
end

class Key
  belongs_to :target, polymorphic: true
  # target_type
  # target_id
end

A.first.key
# SELECT  `keys`.* FROM `keys` WHERE `keys`.`target_id` = 1 AND `keys`.`target_type` = 'A' LIMIT 1

如果想要幫 A 加上 namespace 就會破壞原本的多型關聯

module Test
  class A
    has_one :key, as: :target
  end
end

Test::A.first.key
# SELECT  `keys`.* FROM `keys` WHERE `keys`.`target_id` = 1 AND `keys`.`target_type` = 'Test::A' LIMIT 1
=> nil

如果要讓 has_one 這邊沒問題,要去調整 polymorphic_name 這個 method,才會去調整 query

module Test
  class A
    has_one :key, as: :target

    def self.polymorphic_name
      'A'
    end
  end
end

Test::A.first.key
# SELECT  `keys`.* FROM `keys` WHERE `keys`.`target_id` = 1 AND `keys`.`target_type` = 'A' LIMIT 1

但目前還沒有解決 belongs_to 那一邊的問題

Test::Key.first.target
# uninitialized constant A

在 ActiveRecord 6 之後提供了一個接口 polymorphic_class_for 可以讓我們像這樣子客製化

module Test
  class Key
    belongs_to :target, polymorphic: true

    def self.polymorphic_class_for(name)
      "Test::#{name}".constantize
    end
  end
end

Test::A Load (2.0ms)  SELECT  `as`.* FROM `as` WHERE `as`.`id` = 1 LIMIT 1

至於為什麼是這樣改呢?

去翻了一下 source code,發現其他的關聯的 klass 都是指定自己,只有 polymorphic 的關聯的 klass 是會變動的,用繼承的關係去蓋掉 parent 的 method

# 其他關聯
def klass
  reflection.klass
end

# 多型關聯
module ActiveRecord
  module Associations
    # = Active Record Belongs To Polymorphic Association
    class BelongsToPolymorphicAssociation < BelongsToAssociation #:nodoc:
      def klass
        type = owner[reflection.foreign_type]
        type.presence && owner.class.polymorphic_class_for(type)
      end
# ...

另外前面有提到 ActiveRecord6 以前還沒有這個方式可以修改

# version 6.1
def klass
  type = owner[reflection.foreign_type]
  type.presence && owner.class.polymorphic_class_for(type)
end
# version 5.2.3
def klass
  type = owner[reflection.foreign_type]
  type.presence && type.constantize
end

所以如果還沒升 Rails 版本可以考慮去 patch 原本的 klass method

module ActiveRecord
  module Associations
    class BelongsToPolymorphicAssociation < BelongsToAssociation
      def klass
        type = owner[reflection.foreign_type]
        return nil unless type.presence

        if owner.class.respond_to?(:polymorphic_class_for)
          owner.class.polymorphic_class_for(type)
        else
          type.constantize
        end
      end
    end
  end
end

How to further customize polymorphic relation

找資料的時候看到 這篇文章,想要進一步直接把 target_type 改成自訂的 string ,像是 car 或者 boat

下面是 polymorphic_namepolymorphic_class_for 的 source code

# Returns the value to be stored in the polymorphic type column for Polymorphic Associations.
def polymorphic_name
  store_full_class_name ? base_class.name : base_class.name.demodulize
end

# Returns the class for the provided +name+.
#
# It is used to find the class correspondent to the value stored in the polymorphic type column.
def polymorphic_class_for(name)
  if store_full_class_name
    name.constantize
  else
    compute_type(name)
  end
end

其實跟前面的做法很像,只是要改的方向不同而已

以上面的例子來說,現在我的需求變成在資料庫的 target_type 要存的資料從 A / B 變成 a / b

像是下面這樣修改就可以達到目的

module Test
  module Polymorphicable
    extend ActiveSupport::Concern
    # module with common methods between all vehicles

    CLASS_MAPPING = {
      "a" => Test::A,
      "b" => Test::B
    }

    module ClassMethods
      def polymorphic_name
        CLASS_MAPPING.invert.fetch(self)
      end
    end

    # 這裡的 name 會是 db 裡面 target_type 的值
    def self.polymorphic_class_for(name)
      if CLASS_MAPPING.key?(name)
        CLASS_MAPPING[name]
      end
    end
  end

  class A
    include Test::Polymorphicable
    has_one :key, as: :target
  end

  class B
    include Test::Polymorphicable
    has_one :key, as: :target
  end

  class Key
    belongs_to :target, polymorphic: true
    # target_type
    # target_id

    def self.polymorphic_class_for(name)
      Test::Polymorphicable.polymorphic_class_for(name) || super(name)
    end
  end
end

References

Shopify Tech Blog