開発ノート@HarikoApps

HarikoApps: https://hariko.sprkls.me

2024-09-21

[Rails]ActiveModel::Attributesを用いてモデルのコンポジションを行うためにひとまずカスタムタイプで対応する #20

Railsプロジェクトにて、ActiveModel::AttributesはDBのテーブルにデータを永続化しないモデルを作成するのにとても便利なのですが、オブジェクト指向設計でいうところのコンポジション(合成)のための機能は現状(v7.2.1)では備わっていません。Rails本体のIssueでこの機能のための作業が行われていてリリースが楽しみなのですが、最近このコンポジションを使いたい場面が出てきたので、Rails本体でのリリースまでどう乗り切ろうか調べていたところ、カスタムのタイプ(ActiveModel::Type::Valueクラスに類するクラス)を使えば望んでいることができそうなことがわかりました。

ActiveModel::Attributesではここでタイプの登録が行われており、attributeメソッドにはシンボル以外のタイプを渡すことが可能です。この仕組みを用いてカスタムタイプを用いるために、下記のようにActiveModel::ComposableTypeと名付けた小さなモジュールを定義しました。

module ActiveModel
  module ComposableType
    def self.included(base)
      base.extend(ClassMethods)
    end

    module ClassMethods
      def type = :composable_type

      def []
        item_class = self

        Class.new(ArrayType) do
          self.item_class = item_class
        end
      end

      def cast(value)
        if value.nil?
          nil
        elsif value.instance_of?(self)
          value
        else
          new(value)
        end
      end

      def assert_valid_value(_); end
    end
  end

  module ComposableType
    class ArrayType < Array
      include ActiveModel::ComposableType

      class << self
        attr_accessor :item_class

        def type = :composable_array_type
      end

      def initialize(items)
        super(items.map { |item| self.class.item_class.cast(item) })
      end
    end
  end
end
このモジュールを下記のようにカスタムタイプとしてふるまわせたいモデルクラスにincludeして使用します。

class Cat
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::ComposableType # <=

  attribute :name, :string
  attribute :age, :integer
end

class Address
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::ComposableType # <=

  attribute :country, :string
  attribute :city, :string
end

class Person
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :address, Address, default: Address.new(country: "Unknown", city: "Unknown")
  attribute :pets, Cat[], default: []
end

alice = Person.new(
  name: "Alice",
  address: { country: "USA", city: "NY" },
  pets: [
    {
      name: "Mimi",
      age: 3
    },
    {
      name: "Kitty",
      age: 2
    }
  ]
)

alice.pets.first #=> #<Pet:...
alice.pets.first.name #=> "Mimi"
あくまでRails本体の機能がリリースされるまでのつなぎではあるのですが、少ない行数で実装できたのでしばらくはこれで乗り切れそうです。
Updated at